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() + ); + } + } +} 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); + } + } +} diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 00000000..68c0e2cb --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,260 @@ +# 01. 요구사항 명세 + +## 1. 이 서비스가 풀려는 문제 + +감성 이커머스라는 컨셉 아래, 유저가 브랜드 상품을 탐색하고 좋아요를 누르고, 여러 상품을 한 번에 주문하는 흐름을 만든다. +회원 도메인은 1주차에 완성됐으므로 제외하고, 이번 설계 범위는 **브랜드, 상품, 좋아요, 주문**이다. + +각 도메인이 풀려는 문제를 관점별로 정리하면: + +**사용자 관점** +- 여러 브랜드의 상품을 한 곳에서 탐색하고, 마음에 드는 상품에 관심을 표시하고 싶다. +- 여러 상품을 한 번에 골라서 주문하고, 내 주문 이력을 언제든 확인하고 싶다. +- 과거 주문에서 "그때 얼마였지?"를 확인할 수 있어야 한다 → 주문 시점 가격이 보존되어야 하는 이유. + +**비즈니스 관점** +- 어드민이 브랜드와 상품을 등록/관리할 수 있어야 한다. +- 좋아요 데이터는 단순 기능이 아니라, 향후 랭킹/추천의 기반 데이터가 된다. +- 상품 가격이 변경되더라도 이미 완료된 주문 금액에 영향이 가면 안 된다. + +**시스템 관점** +- 주문 시 재고 차감이 정합성 있게 이루어져야 한다. 재고가 음수가 되거나, 동시 주문으로 초과 차감되면 안 된다. +- 브랜드 삭제 시 소속 상품이 고아 상태로 남으면 안 된다. 하지만 그 상품에 걸린 주문/좋아요 이력은 보존되어야 한다. +- 결제 시스템은 아직 없다. 선결 조건만 충족하면 주문은 즉시 완료된다. 하지만 나중에 결제/쿠폰이 추가될 수 있으므로, 확장 여지를 닫아버리면 안 된다. + +### 액터 + +| 액터 | 설명 | 인증 방식 | +|------|------|-----------| +| 고객 (User) | 상품 탐색, 좋아요, 주문을 수행하는 일반 사용자 | `X-Loopers-LoginId` + `X-Loopers-LoginPw` | +| 어드민 (Admin) | 브랜드/상품/주문을 관리하는 사내 운영자 | `X-Loopers-Ldap: loopers.admin` | + +### 핵심 도메인 + +| 도메인 | 핵심 책임 | +|--------|-----------| +| Brand | 상품을 묶는 브랜드 단위. 어드민이 관리한다. | +| Product | 가격, 재고를 가진 판매 단위. 브랜드에 종속된다. | +| ProductLike | 고객의 상품 관심 표시. 향후 랭킹/추천 데이터 기반이 된다. | +| Order / OrderItem | 고객의 구매 행위. 주문 시점의 상품 정보를 스냅샷으로 보존한다. | + +--- + +## 2. 설계 판단 + +요구사항만으로는 결정할 수 없는 부분들이 많았다. 아래는 고민 과정과 결정 근거를 함께 정리한 것이다. + +### 삭제 전략: Soft Delete + +BaseEntity에 이미 `deletedAt`이 내장되어 있고, 주문 이력과 좋아요 데이터를 보존해야 하므로 soft delete를 사용한다. + +다만 soft delete를 쓰면서 **브랜드 이름에 DB UNIQUE 제약을 거는 건 충돌이 생긴다.** "Nike"를 삭제(soft)한 뒤 새로운 "Nike"를 등록하면 UNIQUE 위반이 난다. 처음엔 `UNIQUE(name, deleted_at)` 복합 유니크를 고민했는데, MySQL에서 NULL이 포함된 유니크 인덱스 동작이 까다롭다. 결국 **DB UNIQUE 제약은 걸지 않고, Application 레벨에서 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름을 검사**하는 방식으로 결정했다. + +### 주문 완료 조건: 선결 조건 충족 = 즉시 완료 + +결제 시스템이 없으므로 주문 상태(CREATED, CONFIRMED, CANCELLED 같은) 관리가 불필요하다. 선결 조건(상품 존재, 재고 충분)만 통과하면 주문은 바로 완료다. 그래서 Order 엔티티에 `status` 필드를 넣지 않았다. + +다만 나중에 결제가 추가되면 status 컬럼을 새로 넣어야 하는 마이그레이션이 발생한다. 이 부분은 인지하고 있지만, 지금 쓰지도 않을 필드를 미리 넣어두는 건 과설계라고 판단했다. 결제가 도입될 때 `ALTER TABLE ADD COLUMN`으로 충분히 대응 가능하다. + +### 좋아요 멱등성: 에러 대신 무시 + +이미 좋아요한 상태에서 다시 POST가 오면 409를 줄지, 그냥 200으로 무시할지 고민했다. 실제 쇼핑몰 앱에서 유저가 좋아요 버튼을 연타하거나, 네트워크 재시도로 같은 요청이 두 번 오는 건 흔한 일이다. 그때마다 에러를 던지면 클라이언트 처리가 번거로워진다. **멱등하게 200 OK를 반환**하는 게 클라이언트 구현도 편하고 안전하다. 취소(DELETE)도 마찬가지 — 이미 취소된 상태에서 다시 요청이 와도 200 OK. + +### 좋아요 목록 조회: 본인만 + +`/api/v1/users/{userId}/likes`에서 userId가 본인이 아닌 경우를 403으로 막는다. 현재 요구사항에 소셜 기능(다른 사람의 좋아요 목록 보기)이 없고, "내가 좋아요 한 상품 목록 조회"라고 명시되어 있다. 사실 URI를 `/api/v1/users/me/likes`로 바꾸는 게 더 자연스러운데, 원본 요구사항의 URI를 그대로 따랐다. + +### 주문 스냅샷 범위 + +주문 시점에 OrderItem에 저장할 정보를 **상품명, 가격, 브랜드명**으로 결정했다. 이미지 URL까지 넣을지 고민했는데, 주문 이력 화면에서 가장 중요한 건 "무슨 상품을, 얼마에, 몇 개 샀는지"다. 이미지는 상품이 살아있으면 원본에서 가져올 수 있고, 삭제됐으면 기본 이미지를 보여주면 된다. 필요해지면 나중에 컬럼을 추가하면 되므로 최소한으로 가져간다. + +### 주문 항목 중복 처리 + +같은 주문 요청에 동일 상품이 여러 번 들어오는 경우를 어떻게 처리할지 — 예를 들어 `[{productId:1, qty:2}, {productId:1, qty:3}]`. 합산해서 qty:5로 처리하는 방법도 있지만, 클라이언트가 보낸 데이터를 서버가 자의적으로 변형하는 건 오히려 혼란을 줄 수 있다. **400으로 거부하고 클라이언트가 정리해서 보내도록 강제**한다. 입력 정합성은 입구에서 막는 게 깔끔하다. + +### 주문 수량 제한 + +한 상품에 수량 제한이 없으면 qty:999999 같은 요청이 들어올 수 있다. 일반적인 쇼핑몰에서 한 번에 99개 이상 주문하는 경우는 거의 없으므로, **단일 항목 최대 수량을 99개로 제한**한다. 이건 비즈니스 정책이므로 나중에 바뀔 수 있지만, 아무 제한이 없는 것보단 낫다. + +### 재고 0 상품 노출 + +재고가 0인 상품을 목록에서 숨길지 말지 — **노출하되 주문만 불가**로 결정했다. 재고가 없다고 숨겨버리면 유저 입장에서 "어? 아까 본 상품이 사라졌네?" 하는 혼란이 생긴다. 품절 상태를 보여주는 게 더 자연스러운 UX다. + +### 어드민 목록에서 삭제된 데이터 + +어드민은 운영자니까 삭제된 브랜드/상품도 확인할 수 있어야 한다. 어드민 목록 조회 API에 **`deleted` 파라미터를 추가**해서, `deleted=true`면 삭제된 것만, `deleted=false`(기본값)면 활성 데이터만 보여주는 방식으로 처리한다. + +### 상품 조회 시 브랜드 정보 조회 + +상품 응답에 브랜드 정보(id, 이름)를 포함해야 하는데, Product 엔티티에는 brandId만 있다. JPA 연관관계를 안 쓰기로 했으므로 두 가지 방법이 있다: +- A) 쿼리 레벨에서 join → 성능은 좋지만 ID 참조 원칙과 다소 충돌 +- B) 상품 조회 후 brandId 목록으로 Brand를 별도 조회 → N+1은 아니고 2번 쿼리 + +**목록 조회는 쿼리 join, 상세 조회는 별도 조회**로 갔다. 목록은 성능이 중요하고, 상세는 한 건이라 브랜드 한 건 더 조회해도 문제없다. + +### 좋아요 수 집계 + +상품 목록에서 좋아요 수를 보여줘야 하고, `likes_desc` 정렬도 지원한다. Product에 `likeCount` 같은 비정규화 필드를 넣을지 고민했는데, 현재 트래픽 규모에서는 **COUNT 서브쿼리로 충분**하다. 비정규화는 카운트 정합성 관리(좋아요 등록/취소 시 동기화) 비용이 생기므로, 성능 문제가 실제로 발생할 때 도입하는 게 맞다. + +--- + +## 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&deleted=false` | LDAP | 등록된 브랜드 목록. `deleted=true`로 삭제된 브랜드도 조회 가능. | +| 브랜드 상세 조회 | 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, 소속 상품 수 + +**브랜드 등록/수정 시 검증:** +- 이름: 필수, 빈 값 불가 +- 이름 중복: 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름 등록 불가 (CONFLICT) + +--- + +### 3.2 상품 (Product) + +#### 고객 API (`/api/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 상품 목록 조회 | GET | `/api/v1/products` | X | 상품 목록 (필터 + 정렬 + 페이지네이션). 삭제된 상품 제외, 재고 0 포함. | +| 상품 상세 조회 | 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, 이름), 좋아요 수 + +> 좋아요 수는 `product_likes` 테이블의 COUNT 서브쿼리로 집계한다. +> 목록 조회 시 브랜드 정보는 쿼리 레벨 join으로 가져온다. + +#### 어드민 API (`/api-admin/v1`) + +| 기능 | METHOD | URI | 인증 | 설명 | +|------|--------|-----|------|------| +| 상품 목록 조회 | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}&deleted=false` | LDAP | 등록된 상품 목록. `deleted=true`로 삭제된 상품도 조회 가능. | +| 상품 상세 조회 | 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 (멱등) + +**좋아요 취소:** +- 삭제된 상품이라도 기존 좋아요는 취소 가능 (상품 검증 생략) +- 이미 취소된 상태에서 재요청 시 200 OK (멱등) + +**좋아요 목록 응답:** +- 좋아요한 상품 정보 (id, 상품명, 가격, 브랜드명, 이미지 URL) +- 삭제된 상품은 DB 쿼리 레벨에서 `products.deleted_at IS NULL` join으로 제외 + +--- + +### 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. 동일 상품 중복 여부 확인 (같은 productId가 두 번 오면 400 거부) +3. 각 항목의 수량이 1~99 범위인지 확인 +4. 요청된 상품들이 모두 존재하고 삭제되지 않았는지 확인 +5. 각 상품의 재고가 요청 수량 이상인지 확인 +6. 재고 차감 +7. 주문 시점의 상품 정보를 OrderItem에 스냅샷으로 저장 (상품명, 가격, 브랜드명) +8. 총 주문 금액 계산 (각 항목의 가격 x 수량의 합) +9. Order 생성 (선결 조건 통과 = 주문 완료) + +**주문 생성 실패 조건:** +- 주문 항목이 비어있음 → 400 +- 동일 상품이 중복으로 포함됨 → 400 +- 수량이 1~99 범위 밖 → 400 +- 상품이 존재하지 않거나 삭제됨 → 404 +- 재고 부족 → 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 | 단일 주문 상세 (주문자 정보 포함) | + +--- + +## 4. 향후 확장 시 영향 범위 + +지금은 스코프 밖이지만, 나중에 추가될 때 현재 설계에 미치는 영향을 미리 정리해둔다. + +| 기능 | 현재 | 추가 시 영향 | +|------|------|-------------| +| 결제 | 없음. 주문 = 완료. | Order에 `status` 컬럼 추가 (`ALTER TABLE`). 주문 생성 시퀀스에서 재고 차감 후 결제 단계 삽입. 실패 시 재고 복원(보상 트랜잭션) 필요. | +| 주문 취소 | 없음 | 취소 API + 재고 복원 로직. status 필드 필요. | +| 쿠폰 | 없음 | 주문 생성 흐름에서 재고 확인과 주문 생성 사이에 쿠폰 검증/적용 단계 삽입. Order에 할인 금액 필드 추가. | +| 동시성 제어 | 기본 구현 (Application 레벨 검증) | 비관적 락(`SELECT FOR UPDATE`) 또는 낙관적 락(`@Version`), DB `CHECK (stock >= 0)` | +| 좋아요 수 비정규화 | COUNT 쿼리 | Product에 `like_count` 컬럼 추가. 좋아요 등록/취소 시 동기화 필요. 정합성 관리 비용 발생. | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 00000000..7c560d43 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,208 @@ +# 02. 시퀀스 다이어그램 + +## 1. 주문 생성 흐름 + +### 왜 이 다이어그램이 필요한가 + +주문 생성은 이 시스템에서 가장 복잡한 흐름이다. +입력 검증 → 상품 존재/삭제 확인 → 재고 확인 → 차감 → 브랜드 조회 → 스냅샷 저장 → 주문 생성이 **하나의 트랜잭션** 안에서 일어나야 하고, 어느 단계에서 실패하든 전체가 롤백되어야 한다. + +특히 검증해야 할 건: +- 요청 레벨 검증(중복 상품, 수량 범위)과 도메인 레벨 검증(상품 존재, 재고)의 **순서와 책임 분리** +- 재고 차감과 주문 저장이 묶이는 **트랜잭션 경계** +- 스냅샷에 필요한 브랜드 정보를 **어디서 가져오는지** + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + actor Client + participant Controller as OrderV1Controller + participant Facade as OrderFacade + participant ProductService as ProductService + participant BrandService as BrandService + participant Product as Product + participant Order as Order + participant OrderRepo as OrderRepository + + Client->>Controller: POST /api/v1/orders
{items: [{productId, quantity}]} + + Note over Controller: 요청 검증
- 항목 비어있는지
- 동일 상품 중복 여부
- 수량 1~99 범위 + + Controller->>Facade: createOrder(userId, command) + + Note over Facade: 트랜잭션 시작 + + Facade->>ProductService: getActiveProducts(productIds) + Note over ProductService: deleted_at IS NULL 필터링 + alt 상품 없음 or 삭제됨 + ProductService-->>Facade: NOT_FOUND + Facade--xClient: 404 + end + 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, products, brands, quantities) + 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. **검증이 두 단계로 나뉜다.** 요청 형식 검증(중복 상품, 수량 범위)은 Controller에서, 도메인 검증(상품 존재, 재고 충분)은 트랜잭션 안에서 한다. 형식이 잘못된 요청은 트랜잭션을 열기도 전에 걸러낸다. +2. **ProductService.getActiveProducts()가 삭제된 상품을 필터링**한다. 메서드 이름에 "Active"를 넣어서 soft delete 필터링이 적용된다는 걸 명시적으로 드러낸다. 요청한 productId 중 하나라도 조회되지 않으면 404. +3. **BrandService 호출이 재고 차감 이후에 있다.** 재고가 부족하면 브랜드를 조회할 필요 자체가 없으므로, 불필요한 쿼리를 아끼기 위해 이 순서로 배치했다. +4. **트랜잭션 하나에 모든 게 묶인다.** 상품이 10개, 20개여도 현재는 하나의 트랜잭션이다. 상품 수가 극단적으로 많아지면 락 시간이 길어지는 리스크가 있지만, 현재 규모에서는 정합성이 더 중요하다. + +--- + +## 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: getActiveProduct(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) + Note over Facade: 상품 존재 검증 생략 + 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. **멱등 처리는 LikeService에서 판단한다.** 이미 좋아요가 있으면 등록을 무시하고, 없으면 삭제를 무시한다. 에러를 던지지 않으므로 클라이언트는 현재 상태를 몰라도 안전하게 요청할 수 있다. +3. **좋아요는 물리 삭제(hard delete)를 사용한다.** 좋아요 이력을 보존할 필요가 없고, 토글 성격이므로 soft delete는 과하다. `(user_id, product_id)` 유니크 제약이 있으므로 soft delete를 쓰면 재등록 시 충돌 문제도 생긴다. + +--- + +## 3. 브랜드 삭제 (어드민) 흐름 + +### 왜 이 다이어그램이 필요한가 + +브랜드 삭제는 **cascade soft delete**가 발생하는 유일한 흐름이다. 브랜드 하나를 삭제하면 소속 상품 전체가 함께 soft delete 되므로 영향 범위가 크다. 기존 주문이나 좋아요 데이터에 영향이 없는지를 검증해야 한다. + +### 다이어그램 + +```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: getActiveBrand(brandId) + alt 브랜드 없음 or 이미 삭제됨 + BrandService-->>Facade: NOT_FOUND + Facade--xAdmin: 404 + end + + Facade->>Brand: delete() + Note over Brand: deletedAt = now() + + Facade->>ProductService: getActiveProductsByBrandId(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 범위를 좋아요까지 넓히면 트랜잭션이 더 비대해진다. 대신 고객이 좋아요 목록을 조회할 때 `products.deleted_at IS NULL` join으로 삭제된 상품을 걸러낸다. + +--- + +## 잠재 리스크 + +| 리스크 | 영향 | 대안 | +|--------|------|------| +| 주문 생성 트랜잭션이 비대해질 수 있음 | 상품 수가 많으면 재고 차감마다 row lock, 트랜잭션 시간 증가 | 향후 비관적 락 도입 시 락 순서를 productId 순으로 고정하여 데드락 방지 | +| 브랜드 삭제 시 상품이 수천 개면 느릴 수 있음 | 트랜잭션 시간 증가, 타임아웃 가능 | 배치 처리 또는 `UPDATE products SET deleted_at = now() WHERE brand_id = ?` 벌크 쿼리로 전환 | +| 좋아요 COUNT 쿼리가 상품 목록 정렬에 사용됨 | `likes_desc` 정렬 시 매번 서브쿼리 집계 필요 | 트래픽 증가 시 Product에 `like_count` 비정규화 필드 도입 | +| 좋아요 목록에서 삭제된 상품 필터링 | join 조건으로 처리하므로 쿼리 복잡도 약간 증가 | 현재 규모에서는 문제 없음. 데이터 증가 시 인덱스 튜닝으로 대응 | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 00000000..18eaefc4 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,309 @@ +# 03. 클래스 다이어그램 + +## 1. JPA 엔티티 모델 + +### 왜 이 다이어그램이 필요한가 + +DB에 영속화되는 JPA 엔티티들의 **필드, 행위, 관계**를 보여준다. +ERD가 "테이블에 뭐가 들어가는가"라면, 이 다이어그램은 "객체가 어떤 비즈니스 규칙을 캡슐화하는가"를 검증한다. + +특히 봐야 할 건: +- 어떤 엔티티가 BaseEntity를 상속하고, 어떤 엔티티가 상속하지 않는지 — 그 이유 +- 엔티티 간 관계가 JPA 연관관계인지, ID 참조인지 +- 비즈니스 로직이 Service가 아닌 엔티티에 있는 경우 그 근거 + +### 다이어그램 + +```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)와 상속하지 않는 엔티티(ProductLike, OrderItem)가 나뉜다.** + - ProductLike는 물리 삭제(hard delete)를 사용한다. soft delete를 쓰면 `(user_id, product_id)` 유니크 제약과 충돌하고, updatedAt도 필요 없다. 그래서 BaseEntity를 상속하지 않고 자체적으로 id, createdAt만 관리한다. + - OrderItem은 Order에 종속된 Composition 관계다. Order가 생성될 때 함께 생성되고, 독립적으로 삭제/수정되지 않는다. + +2. **Product와 Brand는 ID 참조 관계다.** `brandId` 필드(Long 타입)로 연결하며, JPA `@ManyToOne`은 사용하지 않는다. 도메인 간 결합도를 낮추기 위해서다. 단, DB 레벨에서는 FK 제약을 걸어 참조 무결성은 보장한다. "JPA 연관관계 없음 ≠ DB FK 없음"이라는 점이 중요하다. + +3. **재고 관련 로직은 Product 엔티티에 있다.** `decreaseStock()`은 재고 부족 시 예외를 던지고, `hasEnoughStock()`은 충분 여부를 반환한다. 이 로직을 Service에 두면 "재고 검증 없이 차감"하는 실수가 가능해진다. 엔티티 자체가 자기 불변 조건을 지키게 하는 게 안전하다. + +--- + +## 2. 서비스 / 애플리케이션 레이어 + +### 왜 이 다이어그램이 필요한가 + +엔티티가 "무엇을 저장하고, 어떤 규칙을 갖는가"를 정의한다면, Service/Facade는 "누가 엔티티를 조회/조합하고, 트랜잭션을 어떻게 관리하는가"를 정의한다. 이 두 관심사가 섞이면 코드가 금방 복잡해진다. + +특히 봐야 할 건: +- 어떤 Controller가 Service를 직접 호출하고, 어떤 Controller가 Facade를 경유하는지 +- Facade가 존재하는 이유 (여러 도메인 조합이 필요한 경우) +- Service 간 직접 의존이 없는지 + +### 다이어그램 + +```mermaid +classDiagram + direction TB + + namespace interfaces { + class BrandV1Controller { + +getBrand(brandId) ApiResponse + } + class BrandAdminV1Controller { + +listBrands(page, size, deleted) 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, deleted) 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 + +getActiveBrand(brandId) Brand + +getBrands(brandIds) List~Brand~ + } + class ProductService { + +register(brandId, name, desc, price, stock, imageUrl) Product + +getActiveProduct(productId) Product + +getActiveProducts(productIds) List~Product~ + +getActiveProductsByBrandId(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 직접 호출한다.** Facade를 거칠 이유가 없는 경우에 불필요한 레이어를 추가하지 않는다. + - `BrandV1Controller → BrandService`: 고객 브랜드 조회 + - `ProductV1Controller → ProductService`: 고객 상품 목록/상세 조회 + - `OrderAdminV1Controller → OrderService`: 어드민 주문 조회 + +2. **여러 도메인을 조합해야 하면 반드시 Facade를 경유한다.** Facade가 트랜잭션 경계이자 도메인 간 조율자 역할을 한다. + - `OrderFacade`: ProductService(상품 조회 + 재고 차감) + BrandService(브랜드명 조회) + OrderService(주문 저장) + - `BrandFacade`: BrandService(브랜드 삭제) + ProductService(소속 상품 일괄 삭제) + - `ProductFacade`: ProductService(상품 등록/수정) + BrandService(브랜드 존재 여부 검증) + - `ProductLikeFacade`: ProductService(상품 존재 확인) + ProductLikeService(좋아요 처리) + +3. **Service 간에는 직접 의존하지 않는다.** 처음에 ProductService에서 BrandService를 직접 호출하는 방식도 고민했는데, 그러면 Service 간 순환 의존이나 책임 경계 모호함이 생긴다. 여러 Service를 조합해야 하면 항상 Facade에서 한다. + +4. **Service 메서드 이름에 soft delete 필터링 여부를 드러낸다.** `getActiveProduct()`는 삭제된 상품을 제외하고 조회한다. `getProduct()`처럼 모호한 이름 대신, 의도를 명시해서 필터링 누락 실수를 방지한다. + +--- + +## 3. 엔티티 상세 설계 + +### Brand + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| name | String | not null, max 100 | 브랜드명. DB UNIQUE 없음, Application 레벨에서 활성 브랜드 중 중복 검증. | +| description | String | nullable, max 500 | 브랜드 설명 | +| imageUrl | String | nullable, max 500 | 브랜드 이미지 URL | + +### Product + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| brandId | Long | not null | 소속 브랜드 ID. JPA 연관관계 없음, DB FK 있음. | +| 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)`: 재고 차감. `stock < quantity`이면 `CoreException(BAD_REQUEST)` 발생. +- `hasEnoughStock(int quantity)`: 재고 충분 여부 boolean 반환. +- `update(...)`: brandId를 제외한 필드 수정. 브랜드 변경 불가 정책을 엔티티 레벨에서 강제. + +### ProductLike + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | Long | PK, auto increment | - | +| userId | Long | not null | 좋아요한 유저 ID | +| productId | Long | not null | 좋아요 대상 상품 ID | +| createdAt | ZonedDateTime | not null | 좋아요 시점 | + +**BaseEntity를 상속하지 않는다.** soft delete 불필요(물리 삭제), updatedAt 불필요. 자체 id/createdAt만 관리. +**제약:** `(userId, productId)` 유니크 제약. 멱등성의 DB 레벨 안전장치. + +### Order + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| userId | Long | not null | 주문자 ID | +| totalAmount | int | not null, >= 0 | 총 주문 금액 | + +**도메인 메서드:** +- `static create(userId, List)`: 팩토리 메서드. OrderItem 목록을 받아 총 금액을 자동 계산한다. + +### OrderItem + +| 필드 | 타입 | 제약 | 설명 | +|------|------|------|------| +| productId | Long | not null | 원본 상품 ID (참조용, FK 아님) | +| productName | String | not null | 스냅샷: 주문 시점 상품명 | +| productPrice | int | not null | 스냅샷: 주문 시점 단가 | +| brandName | String | not null | 스냅샷: 주문 시점 브랜드명 | +| quantity | int | not null, 1~99 | 주문 수량 | + +**도메인 메서드:** +- `static createSnapshot(product, brand, quantity)`: Product와 Brand에서 필요한 정보만 복사하여 스냅샷을 생성한다. +- `getSubtotal()`: `productPrice * quantity` 반환. + +**Order와 Composition 관계.** `@OneToMany`로 관리되며 독립 생명주기 없음. +**productId에 FK를 걸지 않는 이유:** 원본 상품이 삭제(soft delete)되어도 주문 항목은 보존되어야 한다. FK가 있으면 참조 대상이 "삭제됨"인 상태에서 정합성 문제가 복잡해진다. 조회 시에는 스냅샷 데이터만 사용하므로 원본을 참조할 일이 없다. + +--- + +## 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|--------|------|--------| +| Product-Brand ID 참조 시 정합성 | JPA 연관관계 없이 brandId만 저장하면 코드 레벨에서 존재하지 않는 브랜드 참조 가능 | DB FK 제약으로 기본 보장 + ProductFacade에서 Application 레벨 추가 검증 | +| OrderItem 증가에 따른 Order 조회 성능 | 주문 항목이 많아지면 Order 조회 시 N+1 가능 | fetch join 또는 별도 조회 쿼리 | +| 좋아요 수 실시간 집계 | `likes_desc` 정렬마다 COUNT 서브쿼리 발생 | 현재는 COUNT로 충분. 트래픽 증가 시 Product에 `likeCount` 비정규화 필드 추가 | +| getActiveXxx() 네이밍 규칙 강제 어려움 | 실수로 deleted 포함 조회 메서드를 호출할 가능성 | Repository 레벨에서 기본 조회 시 deleted_at IS NULL 조건 강제 (Custom Repository 또는 `@Where`) | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 00000000..c650ca2e --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,247 @@ +# 04. ERD (Entity-Relationship Diagram) + +## 1. 전체 ERD + +### 왜 이 다이어그램이 필요한가 + +클래스 다이어그램에서 "객체가 어떤 규칙을 갖는가"를 정의했다면, ERD는 "실제 DB에 어떤 테이블이 생기고, 어떤 컬럼과 제약이 걸리는가"를 정의한다. + +이 두 개가 따로 있는 이유가 있다. JPA 엔티티 모델과 DB 스키마가 1:1로 매칭되지 않는 부분이 있기 때문이다. 예를 들어 클래스 다이어그램에서 Product는 `brandId`를 Long으로 들고 있지만, DB에서는 `brand_id`에 FK 제약이 걸린다. "JPA 연관관계 없음 ≠ DB FK 없음"이라는 원칙이 ERD에서 비로소 구체화된다. + +특히 봐야 할 건: +- FK가 걸리는 곳과 걸리지 않는 곳의 차이 — 그 이유 +- 스냅샷 데이터가 원본 테이블과 완전히 분리되어 있는지 +- soft delete 컬럼(`deleted_at`)이 있는 테이블과 없는 테이블의 구분 + +### 다이어그램 + +```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 "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를 걸지 않았다.** 처음에는 FK를 거는 게 정합성에 좋을 것 같았는데, 문제가 있다. 원본 상품이 soft delete 되면 `deleted_at`에 값이 들어가는 거지 row가 사라지는 건 아니니까 FK 자체는 깨지지 않는다. 하지만 나중에 물리 삭제 정책으로 바뀌거나, hard delete로 데이터를 정리해야 하는 상황이 오면 FK가 걸린 order_items 때문에 삭제가 막힌다. 애초에 주문 항목에서 원본 상품을 참조할 일이 없다 — 스냅샷 데이터(product_name, product_price, brand_name)만 쓰니까. 그래서 FK 없이 product_id를 참조용으로만 저장한다. + +2. **brands.name에 UNIQUE 제약이 없다.** 처음에 당연히 UNIQUE를 걸어야 한다고 생각했는데, soft delete와 충돌이 생긴다. "Nike"를 soft delete한 뒤 새로운 "Nike"를 등록하면 DB 레벨에서 UNIQUE 위반이 난다. `UNIQUE(name, deleted_at)` 복합 유니크도 고민했지만 MySQL에서 NULL이 포함된 유니크 인덱스 동작이 직관적이지 않다. 결국 DB UNIQUE는 포기하고, **Application 레벨에서 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름을 검사**하는 방식으로 갔다. 대신 `idx_brands_name` 일반 인덱스는 걸어서 이름 검색 성능은 확보했다. + +3. **soft delete가 있는 테이블과 없는 테이블이 나뉜다.** `deleted_at` 컬럼이 있는 건 brands, products, orders — 이력 보존이 필요한 엔티티들이다. product_likes에는 없다. 좋아요는 토글 성격이라 이력을 남길 이유가 없고, 물리 삭제(hard delete)를 쓴다. order_items에도 없다. Order에 종속된 Composition이라 독립적으로 삭제될 일이 없다. + +4. **product_likes에 `(user_id, product_id)` 유니크 제약이 걸린다.** 같은 유저가 같은 상품에 두 번 좋아요를 남기면 안 되므로, Application에서 체크하는 것과 별개로 DB 레벨 안전장치를 둔다. 멱등 처리를 Application에서만 하면, 동시 요청이 들어왔을 때 둘 다 "없음"으로 판단하고 둘 다 INSERT하는 race condition이 가능하다. UNIQUE 제약이 이걸 막아준다. + +--- + +## 2. 인덱스 설계 + +인덱스는 "어떤 쿼리가 자주 실행될 것인가"를 기준으로 설계했다. API 스펙에서 역산하면 어떤 WHERE/ORDER BY 조건이 나오는지 알 수 있다. + +| 테이블 | 인덱스 | 컬럼 | 왜 필요한가 | +|--------|--------|------|-------------| +| `brands` | `idx_brands_name` | `name` | 브랜드 등록 시 이름 중복 검사. UNIQUE 대신 일반 인덱스 — soft delete 때문. | +| `products` | `idx_products_brand_id` | `brand_id` | 브랜드별 상품 목록 필터링 (`?brandId=...`). FK에 자동 인덱스가 생기는 경우도 있지만 명시적으로 건다. | +| `products` | `idx_products_created_at` | `created_at DESC` | `sort=latest` 정렬. 최신순이 기본 정렬이라 가장 빈번하게 사용된다. | +| `products` | `idx_products_price` | `price ASC` | `sort=price_asc` 정렬. | +| `product_likes` | `uk_product_likes_user_product` | `user_id, product_id` (UNIQUE) | 중복 좋아요 방지 + `findByUserIdAndProductId()` 조회 성능. 유니크 제약이 곧 인덱스 역할. | +| `product_likes` | `idx_product_likes_product_id` | `product_id` | 상품별 좋아요 수 집계. `sort=likes_desc` 정렬 시 COUNT 서브쿼리에서 사용. | +| `product_likes` | `idx_product_likes_user_id` | `user_id` | 유저별 좋아요 목록 조회 (`/api/v1/users/{userId}/likes`). | +| `orders` | `idx_orders_user_id_created_at` | `user_id, created_at` | 유저별 기간 주문 조회. `user_id`로 필터링 후 `created_at` 범위 조건. 복합 인덱스 순서가 중요하다 — 등호 조건(`user_id =`)이 앞, 범위 조건(`created_at BETWEEN`)이 뒤에 와야 인덱스를 제대로 탄다. | +| `order_items` | `idx_order_items_order_id` | `order_id` | 주문 상세 조회 시 해당 주문의 항목들을 가져온다. FK에 의한 자동 인덱스가 생길 수 있지만 명시. | + +`sort=likes_desc` 정렬은 현재 COUNT 서브쿼리로 처리한다. `idx_product_likes_product_id` 인덱스가 있으면 product_id 기준 COUNT가 인덱스 스캔으로 가능하지만, 결국 모든 상품에 대해 COUNT를 해야 하므로 데이터가 많아지면 느려질 수 있다. 트래픽이 증가하면 Product에 `like_count` 비정규화 컬럼을 추가하는 게 맞다. + +--- + +## 3. 테이블별 DDL + +### 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), + INDEX idx_brands_name (name) +); +``` + +`name`에 UNIQUE가 아닌 일반 INDEX를 건 이유는 위에서 설명했다. soft delete 환경에서 DB UNIQUE는 재등록 시 충돌이 생긴다. + +### 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) +); +``` + +`brand_id`에 FK를 건다. JPA에서 `@ManyToOne`을 안 쓴다고 DB FK도 안 거는 건 아니다. JPA 연관관계는 객체 그래프 탐색 편의를 위한 것이고, DB FK는 참조 무결성을 위한 것이다. 존재하지 않는 brand_id가 들어오는 걸 DB 레벨에서 막아야 한다. + +`price`와 `stock`에 `CHECK (price >= 0)`, `CHECK (stock >= 0)` 제약을 걸 수도 있었는데, 현재는 Application 레벨 검증으로 처리한다. 향후 동시성 이슈가 실제로 발생하면 DB CHECK 제약을 추가하는 게 이중 안전장치가 된다. + +### 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) +); +``` + +`updated_at`과 `deleted_at`이 없다. 좋아요는 있거나 없거나 둘 중 하나다. 수정할 일이 없으니 `updated_at`이 필요 없고, 물리 삭제를 쓰니 `deleted_at`도 필요 없다. + +`user_id`와 `product_id` 각각에 단독 인덱스를 건 이유: 유니크 인덱스 `(user_id, product_id)`는 `user_id`가 선행 컬럼이라 `user_id` 단독 조회에는 활용 가능하다. 하지만 `product_id` 단독 조회(좋아요 수 집계)에는 이 유니크 인덱스를 쓸 수 없다. 그래서 `idx_product_likes_product_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) +); +``` + +`status` 컬럼이 없다. 현재는 선결 조건(상품 존재, 재고 충분) 통과 = 주문 완료이므로, 상태를 추적할 필요가 없다. 결제가 도입되면 `ALTER TABLE ADD COLUMN status` 마이그레이션이 필요하겠지만, 지금 쓰지 않는 필드를 미리 넣는 건 과설계다. + +### 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) +); +``` + +`product_id`에 FK를 걸지 않는다. 스냅샷 철학과 일관된 결정이다. 이 테이블의 `product_name`, `product_price`, `brand_name`은 주문 시점에 복사된 데이터이고, 원본이 바뀌거나 삭제되어도 여기 저장된 값은 독립적이다. `product_id`는 "어떤 상품이었는지" 참조할 수 있게 남겨두는 것이지, 원본 데이터를 다시 조회하겠다는 뜻이 아니다. + +`updated_at`과 `deleted_at`이 없다. Order에 종속된 Composition 관계여서 독립적으로 수정/삭제되지 않는다. + +--- + +## 4. 데이터 정합성 전략 + +각 항목에 대해 "왜 이 전략을 선택했는가"를 함께 정리한다. + +| 항목 | 전략 | 왜 이렇게 했는가 | +|------|------|-------------------| +| 좋아요 중복 방지 | `(user_id, product_id)` UNIQUE 제약 | Application 멱등 처리만으로는 동시 요청 시 race condition 가능. DB 레벨 UNIQUE가 최종 안전장치. | +| 재고 음수 방지 | Application 레벨 검증 | `Product.decreaseStock()`에서 재고 부족 시 예외 발생. 현재는 이걸로 충분하지만, 동시 주문이 실제로 문제가 되면 비관적 락 또는 DB `CHECK (stock >= 0)` 추가. | +| 주문-상품 정합성 | 스냅샷 분리 | order_items에 상품 정보를 복사. 원본이 변경/삭제되어도 주문 이력에 영향 없음. FK 없이 product_id만 참조용 저장. | +| 브랜드명 중복 방지 | Application 레벨 검증 | 활성 브랜드(`deleted_at IS NULL`) 중 동일 이름 검사. DB UNIQUE는 soft delete와 충돌하므로 사용 안 함. | +| 브랜드-상품 종속성 | Application Cascade Soft Delete | 브랜드 삭제 시 소속 상품 일괄 soft delete. DB CASCADE DELETE는 물리 삭제이므로 적합하지 않음. | +| Soft Delete 필터링 | `WHERE deleted_at IS NULL` | 고객 API 조회 시 반드시 이 조건을 포함. Service 메서드를 `getActiveXxx()`로 네이밍하여 필터링 적용을 명시적으로 드러낸다. | + +--- + +## 잠재 리스크 + +| 리스크 | 왜 문제가 되는가 | 현재 대응 / 향후 선택지 | +|--------|------------------|------------------------| +| soft delete 필터링 누락 | 삭제된 상품/브랜드가 고객에게 노출된다. `getProduct()`처럼 모호한 메서드를 만들면 실수할 확률이 높다. | 현재: Service 메서드를 `getActiveXxx()`로 네이밍하여 의도를 명시. 향후: `@Where(clause = "deleted_at IS NULL")` 어노테이션으로 강제하는 것도 가능하지만, 어드민 API에서 삭제된 데이터를 조회해야 하는 경우와 충돌할 수 있어서 신중하게 적용해야 한다. | +| 동시 주문 시 재고 초과 차감 | 두 유저가 동시에 마지막 재고 1개를 주문하면, 둘 다 `hasEnoughStock()`을 통과하고 둘 다 `decreaseStock()` 하는 시나리오가 가능하다. 재고가 음수로 내려간다. | 현재: Application 레벨 검증으로 시작. 향후: A) 비관적 락(`SELECT FOR UPDATE`) — 확실하지만 성능 저하. B) 낙관적 락(`@Version`) — 충돌 시 재시도. C) DB 레벨 `CHECK (stock >= 0)` — 음수 진입 자체를 차단. | +| 좋아요 수 집계 성능 | `sort=likes_desc` 정렬마다 모든 상품에 대해 COUNT 서브쿼리를 날린다. 상품과 좋아요 데이터가 많아지면 느려진다. | 현재: `idx_product_likes_product_id` 인덱스로 COUNT 성능 확보. 향후: Product에 `like_count` 비정규화 컬럼 추가. 대신 좋아요 등록/취소 시 카운트 동기화 로직 필요. | +| order_items에 FK 없는 product_id | 이론상 존재하지 않는 product_id가 저장될 수 있다. | Application 레벨에서 주문 생성 시 `getActiveProducts()`로 검증하므로 실질적 문제 없음. 조회 시에는 스냅샷 데이터만 사용하므로 원본 참조 불필요. | +| 브랜드명 Application 레벨 중복 검사 | DB UNIQUE가 없으므로, 동시에 같은 이름의 브랜드가 등록되면 둘 다 통과할 수 있다. | 어드민만 브랜드를 등록하므로 동시 등록 가능성이 매우 낮다. 문제가 되면 `SELECT FOR UPDATE`로 직렬화하거나, 부분 유니크 인덱스(`WHERE deleted_at IS NULL`)를 지원하는 DB로 전환. |