From 5da86c179d30c20844d0317d0f79710130464d05 Mon Sep 17 00:00:00 2001 From: HanJH <71498489+letsgojh0810@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:55:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20User=20API=20=EA=B5=AC=ED=98=84=20(?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85,=20=EB=82=B4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C,=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD)=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/users: 회원가입 - GET /api/v1/users/me: 내 정보 조회 (헤더 인증, 이름 마스킹) - PUT /api/v1/users/password: 비밀번호 변경 검증 규칙: - 로그인 ID: 영문/숫자만 허용 - 비밀번호: 8-16자, 영문/숫자/특수문자, 생년월일 미포함 - 비밀번호 BCrypt 암호화 저장 Co-authored-by: Claude Opus 4.5 --- apps/commerce-api/build.gradle.kts | 3 + .../loopers/application/user/UserFacade.java | 30 ++++ .../loopers/application/user/UserInfo.java | 25 +++ .../java/com/loopers/domain/user/User.java | 143 ++++++++++++++++++ .../loopers/domain/user/UserRepository.java | 10 ++ .../com/loopers/domain/user/UserService.java | 70 +++++++++ .../user/UserJpaRepository.java | 11 ++ .../user/UserRepositoryImpl.java | 35 +++++ .../interfaces/api/user/UserV1ApiSpec.java | 26 ++++ .../interfaces/api/user/UserV1Controller.java | 64 ++++++++ .../interfaces/api/user/UserV1Dto.java | 75 +++++++++ 11 files changed, 492 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f0..6c308aa8 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // security (for password encoding) + implementation("org.springframework.security:spring-security-crypto") + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 00000000..561b2197 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,30 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + public UserInfo register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + User user = userService.register(loginId, rawPassword, name, birthDate, email); + return UserInfo.from(user); + } + + public UserInfo getMe(String loginId, String rawPassword) { + User user = userService.authenticate(loginId, rawPassword); + return UserInfo.from(user); + } + + public void changePassword(String loginId, String currentRawPassword, String newRawPassword) { + User user = userService.authenticate(loginId, currentRawPassword); + userService.changePassword(user.getId(), currentRawPassword, newRawPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 00000000..fc0fdb3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +import java.time.LocalDate; + +public record UserInfo( + Long id, + String loginId, + String name, + String maskedName, + LocalDate birthDate, + String email +) { + public static UserInfo from(User user) { + return new UserInfo( + user.getId(), + user.getLoginId(), + user.getName(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 00000000..a27a0c4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,143 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Entity +@Table(name = "users") +public class User extends BaseEntity { + + private static final String PASSWORD_PATTERN = "^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"; + private static final String LOGIN_ID_PATTERN = "^[a-zA-Z0-9]+$"; + + @Column(name = "login_id", nullable = false, unique = true, length = 50) + private String loginId; + + @Column(name = "password", nullable = false, length = 255) + private String password; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "email", nullable = false, length = 255) + private String email; + + protected User() {} + + public User(String loginId, String encodedPassword, String name, LocalDate birthDate, String email) { + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + + this.loginId = loginId; + this.password = encodedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static void validateRawPassword(String rawPassword, LocalDate birthDate) { + if (rawPassword == null || rawPassword.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 비어있을 수 없습니다."); + } + if (rawPassword.length() < 8 || rawPassword.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!rawPassword.matches(PASSWORD_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용 가능합니다."); + } + if (birthDate != null && containsBirthDate(rawPassword, birthDate)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } + + private static boolean containsBirthDate(String password, LocalDate birthDate) { + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyMMdd")); + String yyyy_MM_dd = birthDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + String yy_MM_dd = birthDate.format(DateTimeFormatter.ofPattern("yy-MM-dd")); + + return password.contains(yyyyMMdd) || + password.contains(yyMMdd) || + password.contains(yyyy_MM_dd) || + password.contains(yy_MM_dd); + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + if (!loginId.matches(LOGIN_ID_PATTERN)) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 사용 가능합니다."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + } + + private void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 미래일 수 없습니다."); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + if (!email.matches("^[^@]+@[^@]+\\.[^@]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + } + + public void changePassword(String newEncodedPassword) { + this.password = newEncodedPassword; + } + + public String getMaskedName() { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } + + public String getLoginId() { + return loginId; + } + + public String getPassword() { + return password; + } + + public String getName() { + return name; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 00000000..9622f111 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + Optional findById(Long id); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 00000000..5be4604f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,70 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Transactional + public User register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + if (userRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); + } + + User.validateRawPassword(rawPassword, birthDate); + String encodedPassword = passwordEncoder.encode(rawPassword); + + User user = new User(loginId, encodedPassword, name, birthDate, email); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User getUser(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public User getUserByLoginId(String loginId) { + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String rawPassword) { + User user = getUserByLoginId(loginId); + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); + } + return user; + } + + @Transactional + public void changePassword(Long userId, String currentRawPassword, String newRawPassword) { + User user = getUser(userId); + + if (!passwordEncoder.matches(currentRawPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + + if (passwordEncoder.matches(newRawPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."); + } + + User.validateRawPassword(newRawPassword, user.getBirthDate()); + String newEncodedPassword = passwordEncoder.encode(newRawPassword); + user.changePassword(newEncodedPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 00000000..fb0e51c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 00000000..d9374677 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return userJpaRepository.existsByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 00000000..0368cdba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User", description = "사용자 API") +public interface UserV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + ApiResponse register(UserV1Dto.RegisterRequest request); + + @Operation(summary = "내 정보 조회", description = "로그인한 사용자의 정보를 조회합니다. 이름은 마스킹 처리됩니다.") + ApiResponse getMe( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password + ); + + @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") + ApiResponse changePassword( + @Parameter(description = "로그인 ID", required = true) String loginId, + @Parameter(description = "비밀번호", required = true) String password, + UserV1Dto.ChangePasswordRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 00000000..942fb5b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,64 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse register( + @Valid @RequestBody UserV1Dto.RegisterRequest request + ) { + UserInfo info = userFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(UserV1Dto.RegisterResponse.from(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password + ) { + UserInfo info = userFacade.getMe(loginId, password); + return ApiResponse.success(UserV1Dto.UserResponse.from(info)); + } + + @PutMapping("/password") + @Override + public ApiResponse changePassword( + @RequestHeader(HEADER_LOGIN_ID) String loginId, + @RequestHeader(HEADER_LOGIN_PW) String password, + @Valid @RequestBody UserV1Dto.ChangePasswordRequest request + ) { + userFacade.changePassword(loginId, password, request.newPassword()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 00000000..26017ffc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDate; + +public class UserV1Dto { + + public record RegisterRequest( + @NotBlank(message = "로그인 ID는 필수입니다.") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "로그인 ID는 영문과 숫자만 사용 가능합니다.") + String loginId, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password, + + @NotBlank(message = "이름은 필수입니다.") + String name, + + @NotNull(message = "생년월일은 필수입니다.") + LocalDate birthDate, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email + ) {} + + public record ChangePasswordRequest( + @NotBlank(message = "현재 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + String newPassword + ) {} + + public record UserResponse( + Long id, + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.id(), + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email() + ); + } + } + + public record RegisterResponse( + Long id, + String loginId, + String name, + LocalDate birthDate, + String email + ) { + public static RegisterResponse from(UserInfo info) { + return new RegisterResponse( + info.id(), + info.loginId(), + info.name(), + info.birthDate(), + info.email() + ); + } + } +} From 0da135ee29c73e0586ed2c09e6d5f2cef2a8fdcf Mon Sep 17 00:00:00 2001 From: HanJH <71498489+letsgojh0810@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:54:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20User=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserTest: 단위 테스트 (엔티티 생성, 비밀번호 검증, 이름 마스킹) - UserServiceIntegrationTest: 통합 테스트 (회원가입, 조회, 인증, 비밀번호 변경) - UserV1ApiE2ETest: E2E 테스트 (API 엔드포인트) Co-authored-by: Claude Opus 4.5 --- .../user/UserServiceIntegrationTest.java | 208 +++++++++++++++ .../com/loopers/domain/user/UserTest.java | 234 +++++++++++++++++ .../interfaces/api/user/UserV1ApiE2ETest.java | 248 ++++++++++++++++++ 3 files changed, 690 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 00000000..d44297a9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,208 @@ +package com.loopers.domain.user; + +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String VALID_LOGIN_ID = "testuser123"; + private static final String VALID_PASSWORD = "Password1!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 5, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 가입 시,") + @Nested + class Register { + + @DisplayName("유효한 정보로 가입하면, 사용자가 생성된다.") + @Test + void createsUser_whenValidInfoIsProvided() { + // arrange & act + User result = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(result.getName()).isEqualTo(VALID_NAME) + ); + } + + @DisplayName("비밀번호가 BCrypt로 암호화되어 저장된다.") + @Test + void encryptsPassword_whenUserIsRegistered() { + // arrange & act + User result = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(result.getPassword()).isNotEqualTo(VALID_PASSWORD), + () -> assertThat(result.getPassword()).startsWith("$2a$") + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenLoginIdAlreadyExists() { + // arrange + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, "다른이름", VALID_BIRTH_DATE, "other@example.com") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("회원 조회 시,") + @Nested + class GetUser { + + @DisplayName("존재하는 ID로 조회하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenUserExists() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + User result = userService.getUser(savedUser.getId()); + + // assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(savedUser.getId()), + () -> assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID) + ); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenUserDoesNotExist() { + // arrange + Long nonExistentId = 999L; + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.getUser(nonExistentId) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("인증 시,") + @Nested + class Authenticate { + + @DisplayName("로그인 ID와 비밀번호가 일치하면, 사용자 정보를 반환한다.") + @Test + void returnsUser_whenCredentialsMatch() { + // arrange + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + User result = userService.authenticate(VALID_LOGIN_ID, VALID_PASSWORD); + + // assert + assertThat(result.getLoginId()).isEqualTo(VALID_LOGIN_ID); + } + + @DisplayName("비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordDoesNotMatch() { + // arrange + userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.authenticate(VALID_LOGIN_ID, "WrongPassword1!") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @DisplayName("현재 비밀번호가 일치하고 새 비밀번호가 유효하면, 비밀번호가 변경된다.") + @Test + void changesPassword_whenCurrentPasswordMatchesAndNewPasswordIsValid() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + String newPassword = "NewPass123!"; + + // act + userService.changePassword(savedUser.getId(), VALID_PASSWORD, newPassword); + + // assert + User updatedUser = userService.getUser(savedUser.getId()); + assertThat(updatedUser.getPassword()).startsWith("$2a$"); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.changePassword(savedUser.getId(), "WrongPassword1!", "NewPass123!") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { + // arrange + User savedUser = userService.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.changePassword(savedUser.getId(), VALID_PASSWORD, VALID_PASSWORD) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 00000000..a5298760 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,234 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + + private static final String VALID_LOGIN_ID = "testuser123"; + private static final String VALID_ENCODED_PASSWORD = "$2a$10$encodedPassword"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 5, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @DisplayName("User 생성 시,") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsUser_whenAllFieldsAreValid() { + // arrange & act + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(user.getPassword()).isEqualTo(VALID_ENCODED_PASSWORD), + () -> assertThat(user.getName()).isEqualTo(VALID_NAME), + () -> assertThat(user.getBirthDate()).isEqualTo(VALID_BIRTH_DATE), + () -> assertThat(user.getEmail()).isEqualTo(VALID_EMAIL) + ); + } + + @DisplayName("로그인 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User(null, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("로그인 ID에 특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User("test@user!", VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, null, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("생년월일이 미래면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBirthDateIsFuture() { + // arrange + LocalDate futureBirthDate = LocalDate.now().plusDays(1); + + // act + CoreException result = assertThrows(CoreException.class, () -> + new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, futureBirthDate, VALID_EMAIL) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 형식이 올바르지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmailFormatIsInvalid() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, "invalid-email") + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("비밀번호 검증 시,") + @Nested + class ValidateRawPassword { + + @DisplayName("유효한 비밀번호면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenPasswordIsValid() { + // arrange & act & assert + assertDoesNotThrow(() -> + User.validateRawPassword("Password1!", VALID_BIRTH_DATE) + ); + } + + @DisplayName("비밀번호가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooShort() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass1!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호가 16자 초과면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsTooLong() { + // arrange + String longPassword = "Password1!" + "a".repeat(7); + + // act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword(longPassword, VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 한글이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsKorean() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass한글1!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate_yyyyMMdd() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass19900515!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("비밀번호에 생년월일(yyMMdd)이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordContainsBirthDate_yyMMdd() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> + User.validateRawPassword("Pass900515!!", VALID_BIRTH_DATE) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름 마스킹 시,") + @Nested + class GetMaskedName { + + @DisplayName("이름이 2자 이상이면, 마지막 글자가 *로 마스킹된다.") + @Test + void masksLastCharacter_whenNameHasMultipleCharacters() { + // arrange + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, "홍길동", VALID_BIRTH_DATE, VALID_EMAIL); + + // act + String maskedName = user.getMaskedName(); + + // assert + assertThat(maskedName).isEqualTo("홍길*"); + } + + @DisplayName("이름이 1자이면, *로 반환된다.") + @Test + void returnsStar_whenNameHasSingleCharacter() { + // arrange + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, "홍", VALID_BIRTH_DATE, VALID_EMAIL); + + // act + String maskedName = user.getMaskedName(); + + // assert + assertThat(maskedName).isEqualTo("*"); + } + } + + @DisplayName("비밀번호 변경 시,") + @Nested + class ChangePassword { + + @DisplayName("새로운 인코딩된 비밀번호로 변경된다.") + @Test + void changesPassword_whenNewEncodedPasswordIsProvided() { + // arrange + User user = new User(VALID_LOGIN_ID, VALID_ENCODED_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + String newEncodedPassword = "$2a$10$newEncodedPassword"; + + // act + user.changePassword(newEncodedPassword); + + // assert + assertThat(user.getPassword()).isEqualTo(newEncodedPassword); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java new file mode 100644 index 00000000..33277bb0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ApiE2ETest.java @@ -0,0 +1,248 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/users"; + private static final String ENDPOINT_ME = "/api/v1/users/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/users/password"; + + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + private static final String VALID_LOGIN_ID = "testuser123"; + private static final String VALID_PASSWORD = "Password1!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(1990, 5, 15); + private static final String VALID_EMAIL = "test@example.com"; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserJpaRepository userJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createTestUser() { + String encodedPassword = passwordEncoder.encode(VALID_PASSWORD); + User user = new User(VALID_LOGIN_ID, encodedPassword, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL); + return userJpaRepository.save(user); + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + @DisplayName("POST /api/v1/users (회원가입)") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 201 CREATED를 반환한다.") + @Test + void returnsCreated_whenValidRequestIsProvided() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(response.getBody().data().name()).isEqualTo(VALID_NAME) + ); + } + + @DisplayName("이미 존재하는 로그인 ID로 가입하면, 409 CONFLICT를 반환한다.") + @Test + void returnsConflict_whenLoginIdAlreadyExists() { + // arrange + createTestUser(); + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + VALID_LOGIN_ID, VALID_PASSWORD, "다른이름", VALID_BIRTH_DATE, "other@example.com" + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("비밀번호가 8자 미만이면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenPasswordIsTooShort() { + // arrange + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest( + VALID_LOGIN_ID, "Pass1!", VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL + ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/users/me (내 정보 조회)") + @Nested + class GetMe { + + @DisplayName("유효한 인증 정보로 조회하면, 마스킹된 이름이 반환된다.") + @Test + void returnsMaskedName_whenValidCredentials() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, VALID_PASSWORD); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(VALID_LOGIN_ID), + () -> assertThat(response.getBody().data().name()).isEqualTo("홍길*") + ); + } + + @DisplayName("비밀번호가 틀리면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenPasswordIsWrong() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, "WrongPassword1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("PUT /api/v1/users/password (비밀번호 변경)") + @Nested + class ChangePassword { + + @DisplayName("유효한 요청이면, 200 OK를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, VALID_PASSWORD); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + VALID_PASSWORD, "NewPassword1!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("현재 비밀번호가 틀리면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenCurrentPasswordIsWrong() { + // arrange + createTestUser(); + HttpHeaders headers = createAuthHeaders(VALID_LOGIN_ID, "WrongPassword1!"); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "WrongPassword1!", "NewPassword1!" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} From ebe81dff08eec231f6925be33c735d95d73336cb Mon Sep 17 00:00:00 2001 From: HanJH <71498489+letsgojh0810@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:32:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=9D=B4=EC=BB=A4=EB=A8=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B4=88=EC=95=88=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브랜드, 상품, 좋아요, 주문 도메인에 대한 설계 문서 4종을 작성한다. - 01-requirements: 유저 시나리오 기반 기능 정의 및 요구사항 명세 - 02-sequence-diagrams: 주문 생성, 좋아요, 브랜드 삭제 시퀀스 - 03-class-diagram: JPA 엔티티 모델 + 서비스/애플리케이션 레이어 구조 - 04-erd: 테이블 구조, 인덱스, 데이터 정합성 전략 Co-authored-by: Claude Opus 4.6 --- docs/design/01-requirements.md | 180 +++++++++++++++++ docs/design/02-sequence-diagrams.md | 209 ++++++++++++++++++++ docs/design/03-class-diagram.md | 294 ++++++++++++++++++++++++++++ docs/design/04-erd.md | 219 +++++++++++++++++++++ 4 files changed, 902 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..3c4afdef --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,180 @@ +# 01. 요구사항 명세 + +## 1. 개요 + +감성 이커머스 플랫폼의 핵심 도메인(브랜드, 상품, 좋아요, 주문)에 대한 기능 요구사항을 정의한다. +회원 도메인은 1주차에 구현 완료되었으므로 이 문서에서 제외한다. + +### 액터 + +| 액터 | 설명 | 인증 방식 | +|------|------|-----------| +| 고객 (User) | 상품 탐색, 좋아요, 주문을 수행하는 일반 사용자 | `X-Loopers-LoginId` + `X-Loopers-LoginPw` | +| 어드민 (Admin) | 브랜드/상품/주문을 관리하는 사내 운영자 | `X-Loopers-Ldap: loopers.admin` | +| 시스템 | 재고 차감, 스냅샷 저장 등 자동화된 내부 처리 | - | + +### 핵심 도메인 + +| 도메인 | 핵심 책임 | +|--------|-----------| +| Brand | 상품을 묶는 브랜드 단위. 어드민이 관리한다. | +| Product | 가격, 재고를 가진 판매 단위. 브랜드에 종속된다. | +| ProductLike | 고객의 상품 관심 표시. 향후 랭킹/추천 데이터 기반이 된다. | +| Order / OrderItem | 고객의 구매 행위. 주문 시점의 상품 정보를 스냅샷으로 보존한다. | + +--- + +## 2. 설계 판단 (보편적 이커머스 기준) + +요구사항에서 명시되지 않은 부분에 대해 다음과 같이 판단했다. + +| 항목 | 결정 | 근거 | +|------|------|------| +| 삭제 전략 | Soft Delete (`deletedAt`) | BaseEntity에 이미 내장. 주문 이력/좋아요 데이터 보존 필요. | +| 주문 완료 조건 | 선결 조건(상품 존재, 재고 충분) 충족 시 즉시 주문 완료 | 결제 없음. 상태 관리 불필요. | +| 좋아요 멱등성 | 이미 좋아요한 상태에서 재요청 시 무시(200 OK) | 클라이언트 구현 편의, 네트워크 재시도 안전성. | +| 좋아요 취소 멱등성 | 이미 취소된 상태에서 재요청 시 무시(200 OK) | 동일 근거. | +| 좋아요 목록 조회 | 본인만 가능 (userId ≠ 로그인 유저 시 403) | 소셜 기능 요구 없음. | +| 주문 스냅샷 범위 | 상품명, 상품 가격, 브랜드명, 수량 | 주문 이력 화면 재현에 충분한 최소 정보. | +| 상품 재고 | Product에 `stock` 필드 | 주문 시 차감 요구사항 충족. | +| 주문 목록 페이지네이션 | 날짜 범위 + page/size | 데이터 증가에 대비. | +| 브랜드 삭제 시 상품 처리 | 브랜드 soft delete → 해당 상품도 일괄 soft delete | 요구사항 명시 + 주문/좋아요 이력 보존. | +| 삭제된 상품 좋아요 | 삭제된 상품에 좋아요 불가, 기존 좋아요는 목록에서 필터링 | 자연스러운 UX. | + +--- + +## 3. 기능 요구사항 + +### 3.1 브랜드 (Brand) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 브랜드 조회 | GET | `/api/v1/brands/{brandId}` | X | 단일 브랜드 정보를 조회한다. 삭제된 브랜드는 404. | + +**고객에게 제공하는 브랜드 정보:** id, 이름, 설명, 이미지 URL + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 브랜드 목록 조회 | GET | `/api-admin/v1/brands?page=0&size=20` | LDAP | 등록된 브랜드 목록 (페이지네이션) | +| 브랜드 상세 조회 | GET | `/api-admin/v1/brands/{brandId}` | LDAP | 단일 브랜드 상세 정보 | +| 브랜드 등록 | POST | `/api-admin/v1/brands` | LDAP | 새 브랜드 등록 | +| 브랜드 수정 | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | 브랜드 정보 수정 | +| 브랜드 삭제 | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | 브랜드 soft delete + 해당 상품 일괄 soft delete | + +**어드민에게 추가 제공하는 정보:** createdAt, updatedAt, deletedAt, 소속 상품 수 + +**브랜드 등록/수정 시 검증:** +- 이름: 필수, 빈 값 불가 +- 이름 중복: 동일 이름 브랜드 등록 불가 (CONFLICT) + +--- + +### 3.2 상품 (Product) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 상품 목록 조회 | GET | `/api/v1/products` | X | 상품 목록 (필터 + 정렬 + 페이지네이션) | +| 상품 상세 조회 | GET | `/api/v1/products/{productId}` | X | 단일 상품 정보. 삭제된 상품은 404. | + +**상품 목록 쿼리 파라미터:** + +| 파라미터 | 타입 | 기본값 | 설명 | +|----------|------|--------|------| +| `brandId` | Long | - | 특정 브랜드 필터링 (선택) | +| `sort` | String | `latest` | 정렬 기준: `latest`, `price_asc`, `likes_desc` | +| `page` | int | 0 | 페이지 번호 | +| `size` | int | 20 | 페이지당 상품 수 | + +**고객에게 제공하는 상품 정보:** id, 상품명, 설명, 가격, 재고, 이미지 URL, 브랜드 정보(id, 이름), 좋아요 수 + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 상품 목록 조회 | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | 등록된 상품 목록 | +| 상품 상세 조회 | GET | `/api-admin/v1/products/{productId}` | LDAP | 상품 상세 정보 | +| 상품 등록 | POST | `/api-admin/v1/products` | LDAP | 새 상품 등록 | +| 상품 수정 | PUT | `/api-admin/v1/products/{productId}` | LDAP | 상품 정보 수정 | +| 상품 삭제 | DELETE | `/api-admin/v1/products/{productId}` | LDAP | 상품 soft delete | + +**상품 등록 시 제약:** +- 브랜드: 반드시 이미 등록된(삭제되지 않은) 브랜드여야 함 +- 상품명: 필수 +- 가격: 0 이상 +- 재고: 0 이상 + +**상품 수정 시 제약:** +- 브랜드는 변경 불가 +- 나머지 필드만 수정 가능 + +--- + +### 3.3 좋아요 (ProductLike) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 좋아요 등록 | POST | `/api/v1/products/{productId}/likes` | O | 상품에 좋아요. 멱등 처리 (이미 있으면 무시). | +| 좋아요 취소 | DELETE | `/api/v1/products/{productId}/likes` | O | 좋아요 해제. 멱등 처리 (없으면 무시). | +| 내 좋아요 목록 | GET | `/api/v1/users/{userId}/likes` | O | 본인이 좋아요한 상품 목록. userId ≠ 본인이면 403. | + +**좋아요 등록 제약:** +- 삭제된 상품에는 좋아요 불가 (404) +- 이미 좋아요한 상태에서 재요청 시 200 OK (멱등) + +**좋아요 목록 응답:** +- 좋아요한 상품 정보 (id, 상품명, 가격, 브랜드명, 이미지 URL) +- 삭제된 상품은 목록에서 제외 + +--- + +### 3.4 주문 (Order) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 주문 생성 | POST | `/api/v1/orders` | O | 다건 상품 주문. 재고 확인 및 차감. 스냅샷 저장. | +| 주문 목록 조회 | GET | `/api/v1/orders?startAt=..&endAt=..&page=0&size=20` | O | 기간별 주문 목록 | +| 주문 상세 조회 | GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 (주문 항목 포함) | + +**주문 생성 요청:** +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +**주문 생성 시 처리 흐름:** +1. 요청된 상품들이 모두 존재하고 삭제되지 않았는지 확인 +2. 각 상품의 재고가 요청 수량 이상인지 확인 +3. 재고 차감 +4. 주문 시점의 상품 정보를 OrderItem에 스냅샷으로 저장 (상품명, 가격, 브랜드명) +5. 총 주문 금액 계산 (각 항목의 가격 x 수량의 합) +6. Order 생성 (선결 조건 통과 = 주문 완료) + +**주문 생성 실패 조건:** +- 상품이 존재하지 않거나 삭제됨 → 404 +- 재고 부족 → 400 (어떤 상품이 부족한지 메시지에 포함) +- 주문 항목이 비어있음 → 400 + +**주문 상세 조회:** +- 본인의 주문만 조회 가능 (타인 주문 접근 시 403) +- 주문 정보: id, 총 금액, 주문 일시 +- 주문 항목: 상품 스냅샷(상품명, 가격, 브랜드명), 수량, 소계 + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 주문 목록 조회 | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | 전체 주문 목록 (페이지네이션) | +| 주문 상세 조회 | GET | `/api-admin/v1/orders/{orderId}` | LDAP | 단일 주문 상세 (주문자 정보 포함) | + diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 00000000..2f1a87a2 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,209 @@ +# 02. 시퀀스 다이어그램 + +## 1. 주문 생성 흐름 + +### 왜 이 다이어그램이 필요한가 + +주문 생성은 이 시스템에서 가장 복잡한 흐름이다. +여러 상품의 재고 확인 → 차감 → 스냅샷 저장 → 주문 생성이 **하나의 트랜잭션** 안에서 일어나야 하며, +어느 단계에서 실패하든 전체가 롤백되어야 한다. + +이 다이어그램으로 검증하려는 것: +- 각 객체의 **책임 분리**가 명확한가 +- **트랜잭션 경계**가 어디인지 +- 실패 시 **어느 시점에서 어떤 예외**가 발생하는지 + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Controller as OrderV1Controller + participant Facade as OrderFacade + participant OrderService as OrderService + participant ProductService as ProductService + participant BrandService as BrandService + participant Order as Order + participant Product as Product + participant OrderRepo as OrderRepository + + Client->>Controller: POST /api/v1/orders
{items: [{productId, quantity}]} + Controller->>Facade: createOrder(userId, request) + + Note over Facade: 트랜잭션 시작 + + Facade->>ProductService: getProducts(productIds) + ProductService-->>Facade: List + + loop 각 주문 항목에 대해 + Facade->>Product: hasEnoughStock(quantity) + alt 재고 부족 + Product-->>Facade: false + Facade--xClient: 400 Bad Request (재고 부족) + end + Facade->>Product: decreaseStock(quantity) + end + + Facade->>BrandService: getBrands(brandIds) + BrandService-->>Facade: List + + Facade->>Order: create(userId, items, products, brands) + Note over Order: 스냅샷 저장
상품명, 가격, 브랜드명 → OrderItem + Note over Order: 총 금액 계산
sum(price × quantity) + + Facade->>OrderRepo: save(order) + + Note over Facade: 트랜잭션 커밋 + + Facade-->>Controller: OrderInfo + Controller-->>Client: 200 OK + 주문 정보 +``` + +### 이 구조에서 봐야 할 포인트 + +1. **트랜잭션 경계는 Facade 레벨**이다. ProductService에서 상품을 조회하고, 재고 차감과 주문 저장이 하나의 트랜잭션으로 묶인다. 하나라도 실패하면 재고 차감도 롤백된다. +2. **Product 도메인 객체가 재고 차감 책임**을 갖는다. Service가 아닌 엔티티에서 `decreaseStock()`을 호출하므로, 비즈니스 규칙(재고 부족 검증)이 도메인에 응집된다. +3. **스냅샷 생성은 Order 생성 시점**에 발생한다. OrderItem이 Product의 현재 상태를 복사하므로, 이후 상품 가격이 변경되어도 주문 이력에는 영향이 없다. + +--- + +## 2. 좋아요 등록/취소 흐름 + +### 왜 이 다이어그램이 필요한가 + +좋아요는 **멱등성**이 핵심이다. +같은 요청이 여러 번 와도 결과가 동일해야 하며, 등록과 취소의 분기가 명확해야 한다. + +이 다이어그램으로 검증하려는 것: +- 멱등 처리 로직이 어느 레이어에서 판단되는가 +- 존재하지 않는 상품에 대한 방어가 되는가 + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Controller as ProductLikeV1Controller + participant Facade as ProductLikeFacade + participant ProductService as ProductService + participant LikeService as ProductLikeService + participant LikeRepo as ProductLikeRepository + + rect rgb(230, 245, 230) + Note right of Client: 좋아요 등록 + Client->>Controller: POST /api/v1/products/{productId}/likes + Controller->>Facade: like(userId, productId) + Facade->>ProductService: getProduct(productId) + alt 상품 없음 or 삭제됨 + ProductService-->>Facade: NOT_FOUND + Facade--xClient: 404 + end + Facade->>LikeService: like(userId, productId) + LikeService->>LikeRepo: findByUserIdAndProductId() + alt 이미 좋아요 상태 + LikeRepo-->>LikeService: 존재함 + LikeService-->>Facade: (무시, 멱등 처리) + else 좋아요 없음 + LikeRepo-->>LikeService: 없음 + LikeService->>LikeRepo: save(new ProductLike) + end + Facade-->>Controller: OK + Controller-->>Client: 200 OK + end + + rect rgb(245, 230, 230) + Note right of Client: 좋아요 취소 + Client->>Controller: DELETE /api/v1/products/{productId}/likes + Controller->>Facade: unlike(userId, productId) + Facade->>LikeService: unlike(userId, productId) + LikeService->>LikeRepo: findByUserIdAndProductId() + alt 좋아요 존재 + LikeRepo-->>LikeService: 존재함 + LikeService->>LikeRepo: delete(productLike) + else 좋아요 없음 + LikeRepo-->>LikeService: 없음 + LikeService-->>Facade: (무시, 멱등 처리) + end + Facade-->>Controller: OK + Controller-->>Client: 200 OK + end +``` + +### 이 구조에서 봐야 할 포인트 + +1. **좋아요 등록 시에만 상품 존재 여부를 확인**한다. 취소 시에는 상품이 삭제되었더라도 기존 좋아요를 제거할 수 있어야 하므로 상품 검증을 생략한다. +2. **멱등 처리는 Service 레벨**에서 판단한다. 이미 좋아요가 있으면 등록을 무시하고, 없으면 삭제를 무시한다. 에러를 던지지 않는다. +3. **물리 삭제(hard delete)를 사용**한다. 좋아요는 이력 보존이 불필요하고 토글 성격이므로, soft delete 대신 실제 레코드를 삭제한다. + +--- + +## 3. 브랜드 삭제 (어드민) 흐름 + +### 왜 이 다이어그램이 필요한가 + +브랜드 삭제는 **연쇄 삭제(cascade soft delete)** 가 발생하는 유일한 흐름이다. +브랜드 하나를 삭제하면 소속 상품 전체가 soft delete 되므로, 영향 범위가 크다. + +이 다이어그램으로 검증하려는 것: +- cascade 범위가 어디까지인지 +- 기존 주문/좋아요 데이터에 영향이 없는지 + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Admin + participant Controller as BrandAdminV1Controller + participant Facade as BrandFacade + participant BrandService as BrandService + participant ProductService as ProductService + participant Brand as Brand + participant Product as Product + + Admin->>Controller: DELETE /api-admin/v1/brands/{brandId} + Controller->>Facade: deleteBrand(brandId) + + Note over Facade: 트랜잭션 시작 + + Facade->>BrandService: getBrand(brandId) + alt 브랜드 없음 or 이미 삭제됨 + BrandService-->>Facade: NOT_FOUND + Facade--xAdmin: 404 + end + + Facade->>Brand: delete() + Note over Brand: deletedAt = now() + + Facade->>ProductService: getProductsByBrandId(brandId) + loop 해당 브랜드의 각 상품 + Facade->>Product: delete() + Note over Product: deletedAt = now() + end + + Note over Facade: 트랜잭션 커밋 + + Facade-->>Controller: OK + Controller-->>Admin: 200 OK + + Note over Admin: 기존 주문의 OrderItem 스냅샷은
영향 없음 (이미 복사된 데이터) + Note over Admin: 기존 좋아요는 유지되나
고객 목록 조회 시 필터링됨 +``` + +### 이 구조에서 봐야 할 포인트 + +1. **브랜드와 소속 상품이 하나의 트랜잭션**으로 처리된다. 상품 삭제 중 실패하면 브랜드 삭제도 롤백된다. +2. **주문 데이터는 안전**하다. OrderItem에 스냅샷으로 저장되어 있으므로, 원본 상품/브랜드가 삭제되어도 주문 이력은 그대로 유지된다. +3. **좋아요 레코드 자체는 삭제하지 않는다.** 대신 고객이 좋아요 목록을 조회할 때, 삭제된 상품을 필터링한다. 이렇게 하면 cascade 범위가 제한되고, 좋아요 수 통계를 나중에 복원할 여지도 남긴다. + +--- + +## 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| 주문 생성 트랜잭션이 비대해질 수 있음 | 상품 수가 많으면 락 시간 증가 | 향후 비관적 락 또는 분산 락 도입 | +| 브랜드 삭제 시 상품이 수천 개면 느릴 수 있음 | 트랜잭션 시간 증가, 타임아웃 가능 | 배치 처리 또는 비동기 삭제로 전환 | +| 좋아요 COUNT 쿼리가 상품 목록 정렬에 사용됨 | `likes_desc` 정렬 시 매번 집계 필요 | 비정규화 카운터 필드 도입 | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 00000000..f81c820c --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,294 @@ +# 03. 클래스 다이어그램 + +## 1. JPA 엔티티 모델 + +### 왜 이 다이어그램이 필요한가 + +DB에 영속화되는 JPA 엔티티들의 **필드, 행위, 관계**를 보여준다. +ERD(테이블 구조)와 대응되는 객체 모델이며, 각 엔티티가 어떤 비즈니스 규칙을 캡슐화하는지를 검증한다. + +### 다이어그램 + +```mermaid +classDiagram + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +delete() void + +restore() void + } + + class Brand { + -String name + -String description + -String imageUrl + +update(name, description, imageUrl) void + } + + class Product { + -Long brandId + -String name + -String description + -int price + -int stock + -String imageUrl + +decreaseStock(quantity) void + +hasEnoughStock(quantity) boolean + +update(name, description, price, stock, imageUrl) void + } + + class ProductLike { + -Long id + -Long userId + -Long productId + -ZonedDateTime createdAt + } + + class Order { + -Long userId + -int totalAmount + -List~OrderItem~ items + +static create(userId, items) Order + -calculateTotalAmount() int + } + + class OrderItem { + -Long productId + -String productName + -int productPrice + -String brandName + -int quantity + +static createSnapshot(product, brand, quantity) OrderItem + +getSubtotal() int + } + + BaseEntity <|-- Brand + BaseEntity <|-- Product + BaseEntity <|-- Order + + Brand "1" --> "*" Product : brandId + Product "1" --> "*" ProductLike : productId + Order "1" *-- "*" OrderItem : items +``` + +### 이 구조에서 봐야 할 포인트 + +1. **BaseEntity를 상속하는 엔티티 (Brand, Product, Order)** 는 soft delete, 생성/수정 시각을 자동으로 갖는다. +2. **BaseEntity를 상속하지 않는 엔티티 (ProductLike, OrderItem)** 는 각각의 이유가 있다. + - ProductLike: 물리 삭제(hard delete) 사용, updatedAt 불필요. 자체 id, createdAt만 관리. + - OrderItem: Order에 종속된 Composition 관계. Order와 생명주기를 같이 한다. +3. **Product와 Brand는 ID 참조** 관계다. `brandId` 필드로 연결하며, JPA `@ManyToOne` 연관관계는 사용하지 않는다. +4. **재고 차감 로직(`decreaseStock`, `hasEnoughStock`)은 Product 엔티티에 있다.** 비즈니스 규칙이 도메인 객체에 응집된다. + +--- + +## 2. 서비스 / 애플리케이션 레이어 + +### 왜 이 다이어그램이 필요한가 + +엔티티는 "무엇을 저장하고, 어떤 규칙을 갖는가"를 정의한다. +반면 Service/Facade는 "누가 엔티티를 조회/조합하고, 트랜잭션을 어떻게 관리하는가"를 정의한다. +이 다이어그램은 **레이어 간 의존 방향과 각 클래스의 역할**을 검증한다. + +### 다이어그램 + +```mermaid +classDiagram + direction TB + + namespace interfaces { + class BrandV1Controller { + +getBrand(brandId) ApiResponse + } + class BrandAdminV1Controller { + +listBrands(page, size) ApiResponse + +getBrand(brandId) ApiResponse + +createBrand(request) ApiResponse + +updateBrand(brandId, request) ApiResponse + +deleteBrand(brandId) ApiResponse + } + class ProductV1Controller { + +listProducts(brandId, sort, page, size) ApiResponse + +getProduct(productId) ApiResponse + } + class ProductAdminV1Controller { + +listProducts(page, size, brandId) ApiResponse + +getProduct(productId) ApiResponse + +createProduct(request) ApiResponse + +updateProduct(productId, request) ApiResponse + +deleteProduct(productId) ApiResponse + } + class ProductLikeV1Controller { + +like(userId, productId) ApiResponse + +unlike(userId, productId) ApiResponse + +getMyLikes(userId) ApiResponse + } + class OrderV1Controller { + +createOrder(userId, request) ApiResponse + +listOrders(userId, startAt, endAt, page, size) ApiResponse + +getOrder(userId, orderId) ApiResponse + } + class OrderAdminV1Controller { + +listOrders(page, size) ApiResponse + +getOrder(orderId) ApiResponse + } + } + + namespace application { + class BrandFacade { + +createBrand(command) BrandInfo + +updateBrand(command) BrandInfo + +deleteBrand(brandId) void + } + class ProductFacade { + +createProduct(command) ProductInfo + +updateProduct(command) ProductInfo + +deleteProduct(productId) void + } + class OrderFacade { + +createOrder(userId, command) OrderInfo + } + class ProductLikeFacade { + +like(userId, productId) void + +unlike(userId, productId) void + } + } + + namespace domain { + class BrandService { + +register(name, description, imageUrl) Brand + +getBrand(brandId) Brand + } + class ProductService { + +register(brandId, name, desc, price, stock, imageUrl) Product + +getProduct(productId) Product + +getProducts(productIds) List~Product~ + +getProductsByBrandId(brandId) List~Product~ + } + class ProductLikeService { + +like(userId, productId) void + +unlike(userId, productId) void + +getLikedProducts(userId) List~ProductLike~ + } + class OrderService { + +createOrder(userId, totalAmount, items) Order + +getOrder(orderId) Order + +getOrdersByUserId(userId, startAt, endAt, page, size) Page~Order~ + } + } + + %% 단순 조회: Controller → Service 직접 + BrandV1Controller --> BrandService + ProductV1Controller --> ProductService + OrderAdminV1Controller --> OrderService + + %% 도메인 조합이 필요한 경우: Controller → Facade + BrandAdminV1Controller --> BrandFacade + ProductAdminV1Controller --> ProductFacade + ProductLikeV1Controller --> ProductLikeFacade + OrderV1Controller --> OrderFacade + + %% Facade → Service 의존 + BrandFacade --> BrandService + BrandFacade --> ProductService + ProductFacade --> ProductService + ProductFacade --> BrandService + OrderFacade --> ProductService + OrderFacade --> OrderService + OrderFacade --> BrandService + ProductLikeFacade --> ProductService + ProductLikeFacade --> ProductLikeService +``` + +### 이 구조에서 봐야 할 포인트 + +1. **단순 조회는 Controller → Service 직접 호출**한다. + - `BrandV1Controller → BrandService`: 고객 브랜드 조회 + - `ProductV1Controller → ProductService`: 고객 상품 목록/상세 조회 + - `OrderAdminV1Controller → OrderService`: 어드민 주문 조회 +2. **도메인 간 조합이 필요하면 Facade를 경유**한다. + - `OrderFacade`: ProductService(재고 확인/차감) + BrandService(브랜드명 조회, 스냅샷용) + OrderService(주문 저장) + - `BrandFacade`: BrandService(브랜드 삭제) + ProductService(소속 상품 일괄 삭제) + - `ProductFacade`: ProductService(상품 등록/수정) + BrandService(브랜드 존재 여부 검증) + - `ProductLikeFacade`: ProductService(상품 존재 확인) + ProductLikeService(좋아요 처리) +3. **의존 방향은 항상 상위 → 하위**다. Controller → Facade → Service → Repository. 역방향 의존은 없다. +4. **Service 간에는 직접 의존하지 않는다.** 여러 Service를 조합해야 하면 반드시 Facade에서 한다. + +--- + +## 3. 엔티티 상세 설계 + +### Brand + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| name | String | not null, max 100 | 브랜드명 (활성 브랜드 중 유니크) | +| description | String | nullable, max 500 | 브랜드 설명 | +| imageUrl | String | nullable, max 500 | 브랜드 이미지 URL | + +### Product + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| brandId | Long | not null | 소속 브랜드 ID | +| name | String | not null, max 200 | 상품명 | +| description | String | nullable, max 1000 | 상품 설명 | +| price | int | not null, >= 0 | 판매 가격 | +| stock | int | not null, >= 0 | 재고 수량 | +| imageUrl | String | nullable, max 500 | 상품 이미지 URL | + +**도메인 메서드:** +- `decreaseStock(int quantity)`: 재고 차감. 재고 부족 시 `CoreException(BAD_REQUEST)` 발생. +- `hasEnoughStock(int quantity)`: 재고 충분 여부 반환. +- `update(...)`: 브랜드 ID를 제외한 필드 수정. + +### ProductLike + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | Long | PK, auto increment | - | +| userId | Long | not null | 좋아요한 유저 ID | +| productId | Long | not null | 좋아요 대상 상품 ID | +| createdAt | ZonedDateTime | not null | 좋아요 시점 | + +**제약:** `(userId, productId)` 유니크 제약으로 중복 방지 +**참고:** BaseEntity를 상속하지 않음. soft delete 불필요, 물리 삭제 사용. + +### Order + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| userId | Long | not null | 주문자 ID | +| totalAmount | int | not null, >= 0 | 총 주문 금액 | + +**도메인 메서드:** +- `static create(userId, List)`: 주문 생성 팩토리. 총 금액을 자동 계산. + +### OrderItem + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| productId | Long | not null | 원본 상품 ID (참조용) | +| productName | String | not null | 스냅샷: 상품명 | +| productPrice | int | not null | 스냅샷: 단가 | +| brandName | String | not null | 스냅샷: 브랜드명 | +| quantity | int | not null, >= 1 | 주문 수량 | + +**도메인 메서드:** +- `static createSnapshot(product, brand, quantity)`: 상품/브랜드 정보를 스냅샷으로 복사하여 OrderItem 생성. +- `getSubtotal()`: `productPrice * quantity` 반환. + +**참고:** Order와 Composition 관계. Order의 `@OneToMany`로 관리되며 독립 생명주기 없음. + +--- + +## 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|--------|------|--------| +| Product-Brand ID 참조 시 정합성 | JPA 연관관계 없이 brandId만 저장하면 존재하지 않는 브랜드 참조 가능 | A) DB FK 제약으로 보장 B) Application 레벨에서 검증 (ProductFacade) | +| OrderItem 증가에 따른 Order 조회 성능 | 주문 항목이 많아지면 Order 조회 시 N+1 가능 | A) fetch join B) 별도 조회 쿼리 | +| 좋아요 수 실시간 집계 | `likes_desc` 정렬마다 COUNT 쿼리 발생 | A) 현재는 COUNT로 충분 B) 향후 Product에 likeCount 비정규화 필드 추가 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 00000000..35214486 --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,219 @@ +# 04. ERD (Entity-Relationship Diagram) + +## 1. 전체 ERD + +### 왜 이 다이어그램이 필요한가 + +ERD는 실제 데이터베이스에 어떤 테이블이 생기고, 테이블 간 관계가 어떻게 연결되는지를 보여준다. +도메인 모델(클래스 다이어그램)과 영속성 구조(ERD) 사이의 간극이 없는지 검증한다. + +검증 포인트: +- 관계의 주인(FK 위치)이 올바른가 +- 스냅샷 데이터가 원본과 분리되어 있는가 +- 정규화 수준이 적절한가 + +### 다이어그램 + +```mermaid +erDiagram + users ||--o{ product_likes : "좋아요" + users ||--o{ orders : "주문" + brands ||--o{ products : "소속 상품" + products ||--o{ product_likes : "좋아요 대상" + orders ||--o{ order_items : "주문 항목" + + users { + bigint id PK "AUTO_INCREMENT" + varchar(50) login_id UK "NOT NULL" + varchar(255) password "NOT NULL" + varchar(100) name "NOT NULL" + date birth_date "NOT NULL" + varchar(255) email "NOT NULL" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + brands { + bigint id PK "AUTO_INCREMENT" + varchar(100) name UK "NOT NULL" + varchar(500) description "NULL" + varchar(500) image_url "NULL" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + products { + bigint id PK "AUTO_INCREMENT" + bigint brand_id FK "NOT NULL → brands.id" + varchar(200) name "NOT NULL" + varchar(1000) description "NULL" + int price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar(500) image_url "NULL" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + product_likes { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL → users.id" + bigint product_id FK "NOT NULL → products.id" + datetime created_at "NOT NULL" + } + + orders { + bigint id PK "AUTO_INCREMENT" + bigint user_id FK "NOT NULL → users.id" + int total_amount "NOT NULL, >= 0" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL" + } + + order_items { + bigint id PK "AUTO_INCREMENT" + bigint order_id FK "NOT NULL → orders.id" + bigint product_id "NOT NULL (참조용, FK 아님)" + varchar(200) product_name "NOT NULL (스냅샷)" + int product_price "NOT NULL (스냅샷)" + varchar(100) brand_name "NOT NULL (스냅샷)" + int quantity "NOT NULL, >= 1" + datetime created_at "NOT NULL" + } +``` + +### 이 구조에서 봐야 할 포인트 + +1. **order_items.product_id는 FK가 아니다.** 원본 상품이 삭제되어도 주문 항목은 남아야 하므로, 외래키 제약을 걸지 않는다. 대신 스냅샷 필드(product_name, product_price, brand_name)에 주문 시점의 데이터가 복사되어 있다. +2. **product_likes에는 `(user_id, product_id)` 유니크 제약**이 걸린다. 같은 유저가 같은 상품에 두 번 좋아요할 수 없다. 이 제약이 멱등성의 DB 레벨 안전장치다. +3. **soft delete 대상은 brands, products, orders**이다. `deleted_at` 컬럼이 NULL이면 활성, 값이 있으면 삭제된 상태다. product_likes와 order_items에는 soft delete가 없다. + +--- + +## 2. 인덱스 설계 + +| 테이블 | 인덱스 | 컬럼 | 용도 | +|--------|--------|------|------| +| `products` | `idx_products_brand_id` | `brand_id` | 브랜드별 상품 필터링 | +| `products` | `idx_products_created_at` | `created_at DESC` | `latest` 정렬 | +| `products` | `idx_products_price` | `price ASC` | `price_asc` 정렬 | +| `product_likes` | `uk_product_likes_user_product` | `user_id, product_id` (UNIQUE) | 중복 좋아요 방지 + 존재 여부 조회 | +| `product_likes` | `idx_product_likes_product_id` | `product_id` | 상품별 좋아요 수 집계 | +| `product_likes` | `idx_product_likes_user_id` | `user_id` | 유저별 좋아요 목록 조회 | +| `orders` | `idx_orders_user_id_created_at` | `user_id, created_at` | 유저별 기간 주문 조회 | +| `order_items` | `idx_order_items_order_id` | `order_id` | 주문별 항목 조회 | + +--- + +## 3. 테이블별 상세 + +### brands + +```sql +CREATE TABLE brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + image_url VARCHAR(500), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_brands_name (name) +); +``` + +### products + +```sql +CREATE TABLE products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(1000), + price INT NOT NULL, + stock INT NOT NULL, + image_url VARCHAR(500), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + CONSTRAINT fk_products_brand_id FOREIGN KEY (brand_id) REFERENCES brands (id), + INDEX idx_products_brand_id (brand_id), + INDEX idx_products_created_at (created_at DESC) +); +``` + +### product_likes + +```sql +CREATE TABLE product_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + CONSTRAINT fk_product_likes_user_id FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_product_likes_product_id FOREIGN KEY (product_id) REFERENCES products (id), + UNIQUE KEY uk_product_likes_user_product (user_id, product_id), + INDEX idx_product_likes_product_id (product_id), + INDEX idx_product_likes_user_id (user_id) +); +``` + +### orders + +```sql +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + total_amount INT NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users (id), + INDEX idx_orders_user_id_created_at (user_id, created_at) +); +``` + +### order_items + +```sql +CREATE TABLE order_items ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_price INT NOT NULL, + brand_name VARCHAR(100) NOT NULL, + quantity INT NOT NULL, + created_at DATETIME(6) NOT NULL, + CONSTRAINT fk_order_items_order_id FOREIGN KEY (order_id) REFERENCES orders (id), + INDEX idx_order_items_order_id (order_id) +); +``` + +> **주의:** `order_items.product_id`에는 FK를 걸지 않는다. 상품이 삭제되어도 주문 항목은 보존되어야 하기 때문이다. + +--- + +## 4. 데이터 정합성 전략 + +| 항목 | 전략 | 설명 | +|------|------|------| +| 좋아요 중복 방지 | UNIQUE 제약 | `(user_id, product_id)` 유니크 인덱스로 DB 레벨에서 보장 | +| 재고 음수 방지 | Application 레벨 검증 | `Product.decreaseStock()`에서 재고 부족 시 예외. 향후 `stock >= 0` CHECK 제약 또는 비관적 락 추가 가능. | +| 주문-상품 정합성 | 스냅샷 분리 | order_items에 상품 정보 복사. 원본 변경/삭제에 독립적. | +| 브랜드-상품 종속성 | Cascade Soft Delete | 브랜드 삭제 시 Application 레벨에서 소속 상품 일괄 soft delete | +| Soft Delete 필터링 | WHERE 조건 | 고객 API 조회 시 `WHERE deleted_at IS NULL` 조건 필수 | + +--- + +## 잠재 리스크 + +| 리스크 | 영향 | 선택지 | +|--------|------|--------| +| soft delete 필터링 누락 | 삭제된 상품/브랜드가 고객에게 노출됨 | A) `@Where` 어노테이션 활용 B) Repository 메서드명에 규칙 적용 (`findActiveBy...`) | +| 동시 주문 시 재고 초과 차감 | 두 유저가 동시에 마지막 1개 주문 시 재고가 음수 | A) 비관적 락 (`SELECT FOR UPDATE`) B) 낙관적 락 (`@Version`) C) DB 레벨 `CHECK (stock >= 0)` | +| 좋아요 수 집계 성능 | `likes_desc` 정렬마다 COUNT 서브쿼리 | A) 현재는 COUNT로 시작 B) 트래픽 증가 시 Product에 `like_count` 컬럼 비정규화 | +| order_items에 FK 없는 product_id | 존재하지 않는 product_id 저장 가능 | Application 레벨에서 주문 생성 시 검증으로 충분. 조회 시에는 스냅샷 데이터만 사용. |