From 9434adc1751cca241c7aaa605cd98c5865813943 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 18:39:06 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=EC=9D=84=20=EC=9C=84=ED=95=9C=20Use?= =?UTF-8?q?r=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - password 컬럼 nullable 허용 (소셜 전용 계정은 비밀번호 없음) - createWithSocial() 정적 팩토리 메서드 추가 --- .../alter/domain/user/entity/User.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java index 7a6d86f5..b25244d6 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java +++ b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java @@ -31,7 +31,7 @@ public class User { @Column(name = "email", length = 255, nullable = true, unique = true) private String email; - @Column(name = "password", length = 255, nullable = false) + @Column(name = "password", length = 255, nullable = true) private String password; @Column(name = "name", length = 12, nullable = false) @@ -99,6 +99,27 @@ public static User create( .build(); } + public static User createWithSocial( + String contact, + String name, + String nickname, + UserGender gender, + String birthday, + String email + ) { + return User.builder() + .email(email) + .password(null) + .name(name) + .nickname(nickname) + .contact(contact) + .birthday(birthday) + .gender(gender) + .role(UserRole.ROLE_USER) + .status(UserStatus.ACTIVE) + .build(); + } + public void updateEmail(String email) { this.email = email; } From 8e6a0b79b096be5cbe93581c9d654e9c6b6f1c5b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 18:39:11 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20UseCase=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateUserWithSocialUseCase 인바운드 포트 추가 - CreateUserWithSocial 유스케이스 구현 - Redis 회원가입 세션 검증 - 닉네임·연락처·소셜ID 중복 확인 - 이메일 인증 세션 선택적 처리 - 소셜 인증 후 User 및 UserSocial 생성 --- .../user/usecase/CreateUserWithSocial.java | 146 ++++++++++++++++++ .../inbound/CreateUserWithSocialUseCase.java | 8 + 2 files changed, 154 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java new file mode 100644 index 00000000..bb18142c --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -0,0 +1,146 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.SocialLoginRequestDto; +import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.AuthLog; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; +import com.dreamteam.alter.domain.auth.type.AuthLogType; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("createUserWithSocial") +@RequiredArgsConstructor +@Transactional +public class CreateUserWithSocial implements CreateUserWithSocialUseCase { + + private static final String KEY_PREFIX = "SIGNUP:PENDING:"; + private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; + + private final UserRepository userRepository; + private final UserQueryRepository userQueryRepository; + private final UserSocialQueryRepository userSocialQueryRepository; + private final SocialAuthenticationManager socialAuthenticationManager; + private final AuthService authService; + private final AuthLogRepository authLogRepository; + private final StringRedisTemplate redisTemplate; + private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + + @Override + public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { + + // Redis 세션에서 휴대폰 인증 정보 확인 + String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); + String contact = redisTemplate.opsForValue().get(sessionIdKey); + + if (ObjectUtils.isEmpty(contact)) { + throw new CustomException(ErrorCode.SIGNUP_SESSION_NOT_EXIST); + } + + // 중복 확인 + validateDuplication(request, contact, sessionIdKey); + + // 소셜 인증 + SocialLoginRequestDto socialAuthRequest = new SocialLoginRequestDto( + request.getProvider(), + request.getOauthToken(), + request.getAuthorizationCode(), + request.getPlatformType() + ); + SocialAuthInfo socialAuthInfo = socialAuthenticationManager.authenticate(socialAuthRequest); + + // 이미 연동된 소셜 계정인지 확인 + if (userSocialQueryRepository.existsBySocialProviderAndSocialId( + socialAuthInfo.getProvider(), socialAuthInfo.getSocialId() + )) { + throw new CustomException(ErrorCode.SOCIAL_ID_DUPLICATED); + } + + // 이메일 인증 세션 검증 (선택) + String verifiedEmail = resolveVerifiedEmail(request); + + // 사용자 생성 + User user = userRepository.save(User.createWithSocial( + contact, + request.getName(), + request.getNickname(), + request.getGender(), + request.getBirthday(), + verifiedEmail + )); + + // 소셜 계정 연동 + UserSocial userSocial = UserSocial.create( + user, + socialAuthInfo.getProvider(), + socialAuthInfo.getSocialId(), + socialAuthInfo.getRefreshToken() + ); + user.addUserSocial(userSocial); + + // 회원가입 세션 삭제 + redisTemplate.delete(sessionIdKey); + redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + + // 이메일 인증 세션 삭제 + if (ObjectUtils.isNotEmpty(verifiedEmail)) { + emailVerificationSessionStoreRepository.deleteSession(request.getEmailSessionId()); + } + + Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); + authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); + + return GenerateTokenResponseDto.of(authorization); + } + + private String resolveVerifiedEmail(CreateUserWithSocialRequestDto request) { + String emailSessionId = request.getEmailSessionId(); + if (ObjectUtils.isEmpty(emailSessionId)) { + return null; + } + + String verifiedEmail = emailVerificationSessionStoreRepository + .getEmailBySession(emailSessionId) + .orElseThrow(() -> new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "이메일 인증 세션이 유효하지 않거나 만료되었습니다.")); + + userQueryRepository.findByEmail(verifiedEmail) + .ifPresent(existing -> { + throw new CustomException(ErrorCode.EMAIL_DUPLICATED); + }); + + return verifiedEmail; + } + + private void validateDuplication(CreateUserWithSocialRequestDto request, String contact, String sessionIdKey) { + // 닉네임 중복 확인 + if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { + redisTemplate.delete(sessionIdKey); + redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); + } + + // 연락처 중복 확인 + if (userQueryRepository.findByContact(contact).isPresent()) { + redisTemplate.delete(sessionIdKey); + redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + throw new CustomException(ErrorCode.USER_CONTACT_DUPLICATED); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java new file mode 100644 index 00000000..f226519b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; + +public interface CreateUserWithSocialUseCase { + GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request); +} From 9da77ccd7930e6dff1cc7874c127db32b2b422f5 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 18:39:16 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20API=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateUserWithSocialRequestDto 추가 (소셜 회원가입 요청 DTO) - POST /signup-social 엔드포인트 구현 - UserPublicControllerSpec에 Swagger 문서 추가 --- .../user/controller/UserPublicController.java | 12 ++++ .../controller/UserPublicControllerSpec.java | 17 +++++ .../dto/CreateUserWithSocialRequestDto.java | 66 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java index ec0e9744..b4fd4076 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java @@ -11,6 +11,7 @@ import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase; import com.dreamteam.alter.domain.user.port.inbound.LoginWithSocialUseCase; import com.dreamteam.alter.domain.user.port.inbound.CreateUserUseCase; +import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; import com.dreamteam.alter.domain.user.port.inbound.CheckContactDuplicationUseCase; import com.dreamteam.alter.domain.user.port.inbound.CheckNicknameDuplicationUseCase; import com.dreamteam.alter.domain.user.port.inbound.CheckEmailDuplicationUseCase; @@ -41,6 +42,9 @@ public class UserPublicController implements UserPublicControllerSpec { @Resource(name = "createUser") private final CreateUserUseCase createUser; + @Resource(name = "createUserWithSocial") + private final CreateUserWithSocialUseCase createUserWithSocial; + @Resource(name = "checkContactDuplication") private final CheckContactDuplicationUseCase checkContactDuplication; @@ -95,6 +99,14 @@ public ResponseEntity> createUser( return ResponseEntity.ok(CommonApiResponse.of(createUser.execute(request))); } + @Override + @PostMapping("/signup-social") + public ResponseEntity> createUserWithSocial( + @Valid @RequestBody CreateUserWithSocialRequestDto request + ) { + return ResponseEntity.ok(CommonApiResponse.of(createUserWithSocial.execute(request))); + } + @Override @PostMapping("/exists/nickname") public ResponseEntity> checkNicknameDuplication( diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java index dee7471a..599ac9d5 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java @@ -93,6 +93,23 @@ public interface UserPublicControllerSpec { }) ResponseEntity> createUser(@Valid CreateUserRequestDto request); + @Operation(summary = "소셜 계정으로 회원가입을 수행한다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "소셜 회원 가입 및 로그인 성공 (JWT 응답)"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "회원 가입 세션이 존재하지 않음", value = "{\"code\" : \"A006\"}"), + @ExampleObject(name = "사용자 닉네임 중복", value = "{\"code\" : \"A008\"}"), + @ExampleObject(name = "사용자 휴대폰 번호 중복", value = "{\"code\" : \"A009\"}"), + @ExampleObject(name = "소셜 플랫폼 ID 중복", value = "{\"code\" : \"A005\"}"), + @ExampleObject(name = "소셜 토큰 만료 (재 로그인 필요)", value = "{\"code\" : \"A007\"}") + })) + }) + ResponseEntity> createUserWithSocial(@Valid CreateUserWithSocialRequestDto request); + @Operation(summary = "사용자 닉네임 중복 체크") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "닉네임 중복 체크 성공") diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java new file mode 100644 index 00000000..0f5895c1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java @@ -0,0 +1,66 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import com.dreamteam.alter.domain.user.type.PlatformType; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import com.dreamteam.alter.domain.user.type.UserGender; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.lang.Nullable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "소셜 회원가입 요청 DTO") +public class CreateUserWithSocialRequestDto { + + @NotBlank + @Size(max = 64) + @Schema(description = "회원가입 세션 ID", example = "UUID") + private String signupSessionId; + + @Nullable + @Size(max = 64) + @Schema(description = "이메일 인증 세션 ID (선택)") + private String emailSessionId; + + @NotNull + @Schema(description = "소셜 로그인 플랫폼", example = "KAKAO") + private SocialProvider provider; + + @Valid + @Schema(description = "OAuth 토큰") + private OauthLoginTokenDto oauthToken; + + @Schema(description = "OAuth 인가 코드", example = "authorizationCode") + private String authorizationCode; + + @NotNull + @Schema(description = "플랫폼 타입", example = "WEB / NATIVE") + private PlatformType platformType; + + @NotBlank + @Size(max = 12) + @Schema(description = "성명", example = "김철수") + private String name; + + @NotBlank + @Size(max = 64) + @Schema(description = "닉네임", example = "유땡땡") + private String nickname; + + @NotNull + @Schema(description = "성별", example = "GENDER_MALE") + private UserGender gender; + + @NotBlank + @Size(min = 8, max = 8) + @Schema(description = "생년월일", example = "YYYYMMDD") + private String birthday; + +} From 37bc25393fce846009e792141cbce8e301c9980b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 18:39:20 +0900 Subject: [PATCH 04/19] =?UTF-8?q?test:=20CreateUserWithSocialUseCase=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세션 미존재, 닉네임/연락처/소셜ID 중복 예외 케이스 - 정상 소셜 회원가입 시나리오 - 이메일 인증 세션 포함 시나리오 --- .../usecase/CreateUserWithSocialTests.java | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java new file mode 100644 index 00000000..fea85f81 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -0,0 +1,268 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.type.PlatformType; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import com.dreamteam.alter.domain.user.type.UserGender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateUserWithSocial 테스트") +class CreateUserWithSocialTests { + + @Mock + private UserRepository userRepository; + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private UserSocialQueryRepository userSocialQueryRepository; + + @Mock + private SocialAuthenticationManager socialAuthenticationManager; + + @Mock + private AuthService authService; + + @Mock + private AuthLogRepository authLogRepository; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + + @InjectMocks + private CreateUserWithSocial createUserWithSocial; + + @Mock + @SuppressWarnings("unchecked") + private ValueOperations valueOperations; + + private CreateUserWithSocialRequestDto request; + + @BeforeEach + void setUp() { + request = new CreateUserWithSocialRequestDto( + "signup-session-id", + null, + SocialProvider.KAKAO, + null, + "auth-code", + PlatformType.WEB, + "김철수", + "유땡땡", + UserGender.GENDER_MALE, + "19900101" + ); + + given(redisTemplate.opsForValue()).willReturn(valueOperations); + } + + private SocialAuthInfo createSocialAuthInfo() { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn("kakao-social-id"); + given(authInfo.getRefreshToken()).willReturn("kakao-refresh-token"); + return authInfo; + } + + private SocialAuthInfo createSocialAuthInfoWithoutToken() { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn("kakao-social-id"); + return authInfo; + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("회원가입 세션이 존재하지 않을 경우 SIGNUP_SESSION_NOT_EXIST 예외 발생") + void fails_whenSignupSessionNotFound() { + // given + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn(null); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SIGNUP_SESSION_NOT_EXIST)); + + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("닉네임이 중복될 경우 NICKNAME_DUPLICATED 예외 발생 및 Redis 세션 삭제") + void fails_whenNicknameDuplicated() { + // given + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); + + then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); + then(redisTemplate).should().delete("SIGNUP:CONTACT:01012345678"); + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("연락처가 중복될 경우 USER_CONTACT_DUPLICATED 예외 발생 및 Redis 세션 삭제") + void fails_whenContactDuplicated() { + // given + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); + + then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); + then(redisTemplate).should().delete("SIGNUP:CONTACT:01012345678"); + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("이미 등록된 소셜 계정일 경우 SOCIAL_ID_DUPLICATED 예외 발생") + void fails_whenSocialIdAlreadyRegistered() { + // given + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfoWithoutToken(); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SOCIAL_ID_DUPLICATED)); + + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("유효한 입력으로 소셜 회원가입 성공") + void succeeds_withValidSocialSignup() { + // given + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfo(); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(false); + + User savedUser = mock(User.class); + given(userRepository.save(any(User.class))).willReturn(savedUser); + + Authorization authorization = mock(Authorization.class); + given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); + + // when + GenerateTokenResponseDto result = createUserWithSocial.execute(request); + + // then + assertThat(result).isNotNull(); + then(userRepository).should().save(any(User.class)); + then(authService).should().generateAuthorization(savedUser, TokenScope.APP); + then(authLogRepository).should().save(any()); + then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); + then(redisTemplate).should().delete("SIGNUP:CONTACT:01012345678"); + } + + @Test + @DisplayName("이메일 세션이 제공된 경우 이메일 인증 처리 및 세션 삭제") + void succeeds_withEmailSessionProvided() { + // given + CreateUserWithSocialRequestDto requestWithEmail = new CreateUserWithSocialRequestDto( + "signup-session-id", + "email-session-id", + SocialProvider.KAKAO, + null, + "auth-code", + PlatformType.WEB, + "김철수", + "유땡땡", + UserGender.GENDER_MALE, + "19900101" + ); + + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfo(); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(false); + + given(emailVerificationSessionStoreRepository.getEmailBySession("email-session-id")) + .willReturn(Optional.of("test@example.com")); + given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.empty()); + + User savedUser = mock(User.class); + given(userRepository.save(any(User.class))).willReturn(savedUser); + + Authorization authorization = mock(Authorization.class); + given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); + + // when + GenerateTokenResponseDto result = createUserWithSocial.execute(requestWithEmail); + + // then + assertThat(result).isNotNull(); + then(emailVerificationSessionStoreRepository).should().getEmailBySession("email-session-id"); + then(emailVerificationSessionStoreRepository).should().deleteSession("email-session-id"); + } + } +} From f477180780546e0421083f1f5c184b13f7987adc Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 18:56:00 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=8B=9C=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=86=8C=EC=85=9C=20=EA=B3=84=EC=A0=95=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=90=EB=8F=99=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소셜 회원가입 시 별도 이메일 인증 없이 소셜 계정에서 제공하는 이메일을 User.email에 바로 저장하도록 변경 - socialAuthInfo.getEmail()로 이메일을 취득하고 중복 확인 후 저장 - 소셜 계정에 이메일이 없는 경우 null로 저장 (기존 nullable 정책 유지) --- .../dto/CreateUserWithSocialRequestDto.java | 6 -- .../user/usecase/CreateUserWithSocial.java | 35 ++------ .../usecase/CreateUserWithSocialTests.java | 84 ++++++++++++------- 3 files changed, 59 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java index 0f5895c1..1c09c574 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java @@ -8,7 +8,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import org.springframework.lang.Nullable; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,11 +23,6 @@ public class CreateUserWithSocialRequestDto { @Schema(description = "회원가입 세션 ID", example = "UUID") private String signupSessionId; - @Nullable - @Size(max = 64) - @Schema(description = "이메일 인증 세션 ID (선택)") - private String emailSessionId; - @NotNull @Schema(description = "소셜 로그인 플랫폼", example = "KAKAO") private SocialProvider provider; diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index bb18142c..c0dd6807 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -13,7 +13,6 @@ import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; import com.dreamteam.alter.domain.auth.type.AuthLogType; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.entity.UserSocial; import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; @@ -41,7 +40,6 @@ public class CreateUserWithSocial implements CreateUserWithSocialUseCase { private final AuthService authService; private final AuthLogRepository authLogRepository; private final StringRedisTemplate redisTemplate; - private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; @Override public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { @@ -73,8 +71,12 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) throw new CustomException(ErrorCode.SOCIAL_ID_DUPLICATED); } - // 이메일 인증 세션 검증 (선택) - String verifiedEmail = resolveVerifiedEmail(request); + // 소셜 계정 이메일 중복 확인 + String email = socialAuthInfo.getEmail(); + if (ObjectUtils.isNotEmpty(email)) { + userQueryRepository.findByEmail(email) + .ifPresent(existing -> { throw new CustomException(ErrorCode.EMAIL_DUPLICATED); }); + } // 사용자 생성 User user = userRepository.save(User.createWithSocial( @@ -83,7 +85,7 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) request.getNickname(), request.getGender(), request.getBirthday(), - verifiedEmail + email )); // 소셜 계정 연동 @@ -99,35 +101,12 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) redisTemplate.delete(sessionIdKey); redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); - // 이메일 인증 세션 삭제 - if (ObjectUtils.isNotEmpty(verifiedEmail)) { - emailVerificationSessionStoreRepository.deleteSession(request.getEmailSessionId()); - } - Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); return GenerateTokenResponseDto.of(authorization); } - private String resolveVerifiedEmail(CreateUserWithSocialRequestDto request) { - String emailSessionId = request.getEmailSessionId(); - if (ObjectUtils.isEmpty(emailSessionId)) { - return null; - } - - String verifiedEmail = emailVerificationSessionStoreRepository - .getEmailBySession(emailSessionId) - .orElseThrow(() -> new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "이메일 인증 세션이 유효하지 않거나 만료되었습니다.")); - - userQueryRepository.findByEmail(verifiedEmail) - .ifPresent(existing -> { - throw new CustomException(ErrorCode.EMAIL_DUPLICATED); - }); - - return verifiedEmail; - } - private void validateDuplication(CreateUserWithSocialRequestDto request, String contact, String sessionIdKey) { // 닉네임 중복 확인 if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index fea85f81..3905509c 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -10,7 +10,6 @@ import com.dreamteam.alter.domain.auth.entity.Authorization; import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import com.dreamteam.alter.domain.user.port.outbound.UserRepository; @@ -34,7 +33,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -66,9 +64,6 @@ class CreateUserWithSocialTests { @Mock private StringRedisTemplate redisTemplate; - @Mock - private EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; - @InjectMocks private CreateUserWithSocial createUserWithSocial; @@ -82,7 +77,6 @@ class CreateUserWithSocialTests { void setUp() { request = new CreateUserWithSocialRequestDto( "signup-session-id", - null, SocialProvider.KAKAO, null, "auth-code", @@ -100,14 +94,32 @@ private SocialAuthInfo createSocialAuthInfo() { SocialAuthInfo authInfo = mock(SocialAuthInfo.class); given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); given(authInfo.getSocialId()).willReturn("kakao-social-id"); + given(authInfo.getEmail()).willReturn("social@example.com"); given(authInfo.getRefreshToken()).willReturn("kakao-refresh-token"); return authInfo; } - private SocialAuthInfo createSocialAuthInfoWithoutToken() { + private SocialAuthInfo createSocialAuthInfoForDuplicateSocialId() { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn("kakao-social-id"); + return authInfo; + } + + private SocialAuthInfo createSocialAuthInfoForDuplicateEmail() { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn("kakao-social-id"); + given(authInfo.getEmail()).willReturn("social@example.com"); + return authInfo; + } + + private SocialAuthInfo createSocialAuthInfoWithoutEmail() { SocialAuthInfo authInfo = mock(SocialAuthInfo.class); given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); given(authInfo.getSocialId()).willReturn("kakao-social-id"); + given(authInfo.getEmail()).willReturn(null); + given(authInfo.getRefreshToken()).willReturn("kakao-refresh-token"); return authInfo; } @@ -175,7 +187,7 @@ void fails_whenSocialIdAlreadyRegistered() { given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfoWithoutToken(); + SocialAuthInfo authInfo = createSocialAuthInfoForDuplicateSocialId(); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(true); @@ -190,7 +202,30 @@ void fails_whenSocialIdAlreadyRegistered() { } @Test - @DisplayName("유효한 입력으로 소셜 회원가입 성공") + @DisplayName("소셜 계정 이메일이 이미 가입된 경우 EMAIL_DUPLICATED 예외 발생") + void fails_whenSocialEmailAlreadyExists() { + // given + given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfoForDuplicateEmail(); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(false); + given(userQueryRepository.findByEmail("social@example.com")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.EMAIL_DUPLICATED)); + + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("유효한 입력으로 소셜 회원가입 성공 - 소셜 계정 이메일 자동 저장") void succeeds_withValidSocialSignup() { // given given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); @@ -201,6 +236,7 @@ void succeeds_withValidSocialSignup() { given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(false); + given(userQueryRepository.findByEmail("social@example.com")).willReturn(Optional.empty()); User savedUser = mock(User.class); given(userRepository.save(any(User.class))).willReturn(savedUser); @@ -214,6 +250,7 @@ void succeeds_withValidSocialSignup() { // then assertThat(result).isNotNull(); then(userRepository).should().save(any(User.class)); + then(userQueryRepository).should().findByEmail("social@example.com"); then(authService).should().generateAuthorization(savedUser, TokenScope.APP); then(authLogRepository).should().save(any()); then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); @@ -221,35 +258,18 @@ void succeeds_withValidSocialSignup() { } @Test - @DisplayName("이메일 세션이 제공된 경우 이메일 인증 처리 및 세션 삭제") - void succeeds_withEmailSessionProvided() { + @DisplayName("소셜 계정에 이메일이 없을 경우 이메일 없이 회원가입 성공") + void succeeds_withNoEmailFromSocialAccount() { // given - CreateUserWithSocialRequestDto requestWithEmail = new CreateUserWithSocialRequestDto( - "signup-session-id", - "email-session-id", - SocialProvider.KAKAO, - null, - "auth-code", - PlatformType.WEB, - "김철수", - "유땡땡", - UserGender.GENDER_MALE, - "19900101" - ); - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfo(); + SocialAuthInfo authInfo = createSocialAuthInfoWithoutEmail(); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(false); - given(emailVerificationSessionStoreRepository.getEmailBySession("email-session-id")) - .willReturn(Optional.of("test@example.com")); - given(userQueryRepository.findByEmail("test@example.com")).willReturn(Optional.empty()); - User savedUser = mock(User.class); given(userRepository.save(any(User.class))).willReturn(savedUser); @@ -257,12 +277,12 @@ void succeeds_withEmailSessionProvided() { given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); // when - GenerateTokenResponseDto result = createUserWithSocial.execute(requestWithEmail); + GenerateTokenResponseDto result = createUserWithSocial.execute(request); // then assertThat(result).isNotNull(); - then(emailVerificationSessionStoreRepository).should().getEmailBySession("email-session-id"); - then(emailVerificationSessionStoreRepository).should().deleteSession("email-session-id"); + then(userRepository).should().save(any(User.class)); + then(userQueryRepository).should(never()).findByEmail(any()); } } } From 2afec7f4fd8f9a7e36997a44f1ef249b8d91ed5b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 19:39:45 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20UseCase?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UnlinkSocialAccountUseCase 추가 (DELETE /app/users/social/unlink) - 비밀번호 없는 사용자가 마지막 소셜 계정 해제 시 차단 (A016) - UserSocial hard delete: UserSocialRepository command port + UserSocialJpaRepository 신규 추가 - UserSocialQueryRepository에 findByUserIdAndSocialProvider, countByUserId 추가 - ErrorCode A015(SOCIAL_ACCOUNT_NOT_LINKED), A016(SOCIAL_UNLINK_NOT_ALLOWED), A017(INVALID_CURRENT_PASSWORD) 추가 --- .../user/controller/UserSocialController.java | 22 ++- .../controller/UserSocialControllerSpec.java | 22 +++ .../dto/UnlinkSocialAccountRequestDto.java | 19 +++ .../persistence/UserSocialJpaRepository.java | 7 + .../UserSocialQueryRepositoryImpl.java | 26 ++++ .../persistence/UserSocialRepositoryImpl.java | 18 +++ .../user/usecase/UnlinkSocialAccount.java | 42 ++++++ .../alter/common/exception/ErrorCode.java | 3 + .../inbound/UnlinkSocialAccountUseCase.java | 8 ++ .../outbound/UserSocialQueryRepository.java | 8 +- .../port/outbound/UserSocialRepository.java | 7 + .../usecase/UnlinkSocialAccountTests.java | 135 ++++++++++++++++++ 12 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java index bc255916..0bb98b52 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java @@ -2,9 +2,12 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto; -import com.dreamteam.alter.application.user.usecase.LinkSocialAccount; +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; import com.dreamteam.alter.application.aop.AppActionContext; import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.port.inbound.LinkSocialAccountUseCase; +import com.dreamteam.alter.domain.user.port.inbound.UnlinkSocialAccountUseCase; +import jakarta.annotation.Resource; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -15,7 +18,11 @@ @RequiredArgsConstructor public class UserSocialController implements UserSocialControllerSpec { - private final LinkSocialAccount linkSocialAccount; + @Resource(name = "linkSocialAccount") + private final LinkSocialAccountUseCase linkSocialAccount; + + @Resource(name = "unlinkSocialAccount") + private final UnlinkSocialAccountUseCase unlinkSocialAccount; @Override @PostMapping("/link") @@ -23,8 +30,17 @@ public ResponseEntity> linkSocialAccount( @Valid @RequestBody LinkSocialAccountRequestDto request ) { AppActor actor = AppActionContext.getInstance().getActor(); - linkSocialAccount.execute(actor, request); return ResponseEntity.ok(CommonApiResponse.empty()); } + + @Override + @DeleteMapping("/unlink") + public ResponseEntity> unlinkSocialAccount( + @Valid @RequestBody UnlinkSocialAccountRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + unlinkSocialAccount.execute(actor, request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java index 7e085fc1..8cf8331e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java @@ -2,6 +2,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -39,4 +40,25 @@ public interface UserSocialControllerSpec { )) }) ResponseEntity> linkSocialAccount(@Valid LinkSocialAccountRequestDto request); + + @Operation(summary = "소셜 계정 연동 해제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "소셜 계정 연동 해제 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse.class), + examples = { + @ExampleObject( + name = "연동되지 않은 소셜 플랫폼", + value = "{\"code\": \"A015\", \"message\": \"연동되지 않은 소셜 플랫폼입니다.\"}" + ), + @ExampleObject( + name = "마지막 소셜 계정 해제 불가", + value = "{\"code\": \"A016\", \"message\": \"비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다.\"}" + ) + } + )) + }) + ResponseEntity> unlinkSocialAccount(@Valid UnlinkSocialAccountRequestDto request); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java new file mode 100644 index 00000000..9c8b115e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import com.dreamteam.alter.domain.user.type.SocialProvider; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "소셜 계정 연동 해제 요청 DTO") +public class UnlinkSocialAccountRequestDto { + + @NotNull + @Schema(description = "해제할 소셜 플랫폼", example = "KAKAO") + private SocialProvider provider; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java new file mode 100644 index 00000000..c1da584f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import com.dreamteam.alter.domain.user.entity.UserSocial; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserSocialJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java index 3b29553e..4912ca45 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java @@ -18,6 +18,32 @@ public class UserSocialQueryRepositoryImpl implements UserSocialQueryRepository private final JPAQueryFactory queryFactory; + @Override + public Optional findByUserIdAndSocialProvider(Long userId, SocialProvider socialProvider) { + QUserSocial qUserSocial = QUserSocial.userSocial; + + UserSocial userSocial = queryFactory.selectFrom(qUserSocial) + .where( + qUserSocial.user.id.eq(userId), + qUserSocial.socialProvider.eq(socialProvider) + ) + .fetchOne(); + + return Optional.ofNullable(userSocial); + } + + @Override + public long countByUserId(Long userId) { + QUserSocial qUserSocial = QUserSocial.userSocial; + + Long count = queryFactory.select(qUserSocial.count()) + .from(qUserSocial) + .where(qUserSocial.user.id.eq(userId)) + .fetchOne(); + + return count != null ? count : 0L; + } + @Override public Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId) { QUserSocial qUserSocial = QUserSocial.userSocial; diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java new file mode 100644 index 00000000..0ade0450 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserSocialRepositoryImpl implements UserSocialRepository { + + private final UserSocialJpaRepository userSocialJpaRepository; + + @Override + public void delete(UserSocial userSocial) { + userSocialJpaRepository.delete(userSocial); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java new file mode 100644 index 00000000..e46153ac --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java @@ -0,0 +1,42 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.inbound.UnlinkSocialAccountUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; + +@Service("unlinkSocialAccount") +@RequiredArgsConstructor +@Transactional +public class UnlinkSocialAccount implements UnlinkSocialAccountUseCase { + + private final UserSocialQueryRepository userSocialQueryRepository; + private final UserSocialRepository userSocialRepository; + + @Override + public void execute(AppActor actor, UnlinkSocialAccountRequestDto request) { + User user = actor.getUser(); + + UserSocial userSocial = userSocialQueryRepository + .findByUserIdAndSocialProvider(user.getId(), request.getProvider()) + .orElseThrow(() -> new CustomException(ErrorCode.SOCIAL_ACCOUNT_NOT_LINKED)); + + if (ObjectUtils.isEmpty(user.getPassword())) { + long count = userSocialQueryRepository.countByUserId(user.getId()); + if (count <= 1) { + throw new CustomException(ErrorCode.SOCIAL_UNLINK_NOT_ALLOWED); + } + } + + userSocialRepository.delete(userSocial); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index d3702580..700f91d6 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -21,6 +21,9 @@ public enum ErrorCode { SOCIAL_PROVIDER_ALREADY_LINKED(400, "A012", "이미 연동되어 있는 소셜 플랫폼입니다."), PASSWORD_RESET_SESSION_NOT_EXIST(400, "A013", "비밀번호 재설정 세션이 존재하지 않거나 만료되었습니다."), INVALID_PASSWORD_FORMAT(400, "A014", "비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."), + SOCIAL_ACCOUNT_NOT_LINKED(400, "A015", "연동되지 않은 소셜 플랫폼입니다."), + SOCIAL_UNLINK_NOT_ALLOWED(400, "A016", "비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다."), + INVALID_CURRENT_PASSWORD(400, "A017", "현재 비밀번호가 올바르지 않습니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java new file mode 100644 index 00000000..93aecc4f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface UnlinkSocialAccountUseCase { + void execute(AppActor actor, UnlinkSocialAccountRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java index 99699956..2257fc71 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java @@ -8,8 +8,12 @@ public interface UserSocialQueryRepository { Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); - + + Optional findByUserIdAndSocialProvider(Long userId, SocialProvider socialProvider); + boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); - + boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialProvider); + + long countByUserId(Long userId); } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java new file mode 100644 index 00000000..b4f438b3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.user.port.outbound; + +import com.dreamteam.alter.domain.user.entity.UserSocial; + +public interface UserSocialRepository { + void delete(UserSocial userSocial); +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java new file mode 100644 index 00000000..ca98b99d --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java @@ -0,0 +1,135 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UnlinkSocialAccount 테스트") +class UnlinkSocialAccountTests { + + @Mock + private UserSocialQueryRepository userSocialQueryRepository; + + @Mock + private UserSocialRepository userSocialRepository; + + @InjectMocks + private UnlinkSocialAccount unlinkSocialAccount; + + private AppActor actor; + private User user; + private UserSocial userSocial; + private UnlinkSocialAccountRequestDto request; + + @BeforeEach + void setUp() { + user = mock(User.class); + actor = mock(AppActor.class); + userSocial = mock(UserSocial.class); + request = new UnlinkSocialAccountRequestDto(SocialProvider.KAKAO); + + given(actor.getUser()).willReturn(user); + given(user.getId()).willReturn(1L); + } + + @Test + @DisplayName("연동되지 않은 provider 해제 시 SOCIAL_ACCOUNT_NOT_LINKED 예외 발생") + void execute_withNotLinkedProvider_throwsException() { + // given + given(userSocialQueryRepository.findByUserIdAndSocialProvider(1L, SocialProvider.KAKAO)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> unlinkSocialAccount.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SOCIAL_ACCOUNT_NOT_LINKED); + then(userSocialRepository).shouldHaveNoInteractions(); + } + + @Nested + @DisplayName("비밀번호가 있는 사용자") + class UserWithPassword { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn("encodedPassword"); + given(userSocialQueryRepository.findByUserIdAndSocialProvider(1L, SocialProvider.KAKAO)) + .willReturn(Optional.of(userSocial)); + } + + @Test + @DisplayName("연동된 소셜 계정을 정상 해제한다") + void execute_withLinkedSocial_succeeds() { + // when & then + assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); + then(userSocialRepository).should().delete(userSocial); + } + + @Test + @DisplayName("소셜 계정이 1개뿐이어도 해제 가능하다 (제약은 비밀번호 없는 사용자에만 적용)") + void execute_withSingleSocial_succeeds() { + // when & then + assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); + then(userSocialRepository).should().delete(userSocial); + } + } + + @Nested + @DisplayName("비밀번호가 없는 소셜 전용 사용자") + class SocialOnlyUser { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn(null); + given(userSocialQueryRepository.findByUserIdAndSocialProvider(1L, SocialProvider.KAKAO)) + .willReturn(Optional.of(userSocial)); + } + + @Test + @DisplayName("소셜 계정이 2개일 때 1개 해제 성공") + void execute_withMultipleSocials_succeeds() { + // given + given(userSocialQueryRepository.countByUserId(1L)).willReturn(2L); + + // when & then + assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); + then(userSocialRepository).should().delete(userSocial); + } + + @Test + @DisplayName("마지막 소셜 계정(1개) 해제 시도 시 SOCIAL_UNLINK_NOT_ALLOWED 예외 발생") + void execute_withSingleSocial_throwsException() { + // given + given(userSocialQueryRepository.countByUserId(1L)).willReturn(1L); + + // when & then + assertThatThrownBy(() -> unlinkSocialAccount.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SOCIAL_UNLINK_NOT_ALLOWED); + then(userSocialRepository).shouldHaveNoInteractions(); + } + } +} From ed3f6c497a80761467b02453c711f428465fb55e Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Apr 2026 19:39:53 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20UseCase=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UpdatePasswordUseCase 추가 (PUT /app/users/me/password) - 비밀번호 있는 사용자: currentPassword 검증 후 변경 (A017) - 소셜 전용 사용자(password=null): currentPassword 없이 바로 신규 설정 --- .../user/controller/UserSelfController.java | 13 ++ .../controller/UserSelfControllerSpec.java | 21 +++ .../user/dto/UpdatePasswordRequestDto.java | 23 +++ .../user/usecase/UpdatePassword.java | 40 +++++ .../port/inbound/UpdatePasswordUseCase.java | 8 + .../user/usecase/UpdatePasswordTests.java | 144 ++++++++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java create mode 100644 src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java create mode 100644 src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java index dee7d4bc..c26e6326 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java @@ -57,6 +57,9 @@ public class UserSelfController implements UserSelfControllerSpec { @Resource(name = "verifyEmailVerificationCode") private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode; + @Resource(name = "updatePassword") + private final UpdatePasswordUseCase updatePassword; + @Override @GetMapping public ResponseEntity> getUserSelfInfo() { @@ -154,4 +157,14 @@ public ResponseEntity> return ResponseEntity.ok(CommonApiResponse.of(verifyEmailVerificationCode.execute(request))); } + @Override + @PutMapping("/password") + public ResponseEntity> updatePassword( + @Valid @RequestBody UpdatePasswordRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + updatePassword.execute(actor, request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java index bee596cc..13fa8488 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java @@ -215,4 +215,25 @@ ResponseEntity> updateUserSelfCertificate( }) ResponseEntity> verifyVerificationCode(@RequestBody @Valid VerifyEmailVerificationCodeRequestDto request); + @Operation(summary = "비밀번호 변경", description = "비밀번호가 설정된 사용자는 currentPassword 필수. 소셜 전용 사용자는 생략 가능.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "현재 비밀번호 불일치", + value = "{\"code\": \"A017\", \"message\": \"현재 비밀번호가 올바르지 않습니다.\"}" + ), + @ExampleObject( + name = "비밀번호 형식 오류", + value = "{\"code\": \"A014\", \"message\": \"비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.\"}" + ) + } + )) + }) + ResponseEntity> updatePassword(@RequestBody @Valid UpdatePasswordRequestDto request); + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java new file mode 100644 index 00000000..9e66b2a2 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java @@ -0,0 +1,23 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "비밀번호 변경 요청 DTO") +public class UpdatePasswordRequestDto { + + @Schema(description = "현재 비밀번호 (비밀번호가 설정된 사용자는 필수, 소셜 전용 사용자는 생략 가능)", example = "currentPass1!") + private String currentPassword; + + @NotBlank + @Size(min = 8, max = 16) + @Schema(description = "새 비밀번호 (8~16자, 영문·숫자·특수문자 각 1개 이상)", example = "newPass1!") + private String newPassword; +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java new file mode 100644 index 00000000..2f02913e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java @@ -0,0 +1,40 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UpdatePasswordRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.util.PasswordValidator; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.UpdatePasswordUseCase; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("updatePassword") +@RequiredArgsConstructor +@Transactional +public class UpdatePassword implements UpdatePasswordUseCase { + + private final PasswordEncoder passwordEncoder; + + @Override + public void execute(AppActor actor, UpdatePasswordRequestDto request) { + User user = actor.getUser(); + + if (ObjectUtils.isNotEmpty(user.getPassword())) { + if (ObjectUtils.isEmpty(request.getCurrentPassword()) || + !passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) { + throw new CustomException(ErrorCode.INVALID_CURRENT_PASSWORD); + } + } + + if (!PasswordValidator.isValid(request.getNewPassword())) { + throw new CustomException(ErrorCode.INVALID_PASSWORD_FORMAT); + } + + user.updatePassword(passwordEncoder.encode(request.getNewPassword())); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java new file mode 100644 index 00000000..196544ec --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UpdatePasswordRequestDto; +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface UpdatePasswordUseCase { + void execute(AppActor actor, UpdatePasswordRequestDto request); +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java new file mode 100644 index 00000000..038368e1 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java @@ -0,0 +1,144 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UpdatePasswordRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UpdatePassword 테스트") +class UpdatePasswordTests { + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UpdatePassword updatePassword; + + private AppActor actor; + private User user; + + @BeforeEach + void setUp() { + user = mock(User.class); + actor = mock(AppActor.class); + given(actor.getUser()).willReturn(user); + } + + @Nested + @DisplayName("비밀번호가 있는 사용자") + class UserWithPassword { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn("encodedCurrentPassword"); + } + + @Test + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경 성공") + void execute_withCorrectCurrentPassword_succeeds() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto("currentPass1!", "newPass1!"); + given(passwordEncoder.matches("currentPass1!", "encodedCurrentPassword")).willReturn(true); + given(passwordEncoder.encode("newPass1!")).willReturn("encodedNewPassword"); + + // when & then + assertThatNoException().isThrownBy(() -> updatePassword.execute(actor, request)); + then(user).should().updatePassword("encodedNewPassword"); + } + + @Test + @DisplayName("틀린 현재 비밀번호 입력 시 INVALID_CURRENT_PASSWORD 예외 발생") + void execute_withWrongCurrentPassword_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto("wrongPass1!", "newPass1!"); + given(passwordEncoder.matches("wrongPass1!", "encodedCurrentPassword")).willReturn(false); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_CURRENT_PASSWORD); + then(user).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("현재 비밀번호를 null로 전송 시 INVALID_CURRENT_PASSWORD 예외 발생") + void execute_withNullCurrentPassword_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto(null, "newPass1!"); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_CURRENT_PASSWORD); + then(user).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("유효하지 않은 새 비밀번호 형식으로 변경 시 INVALID_PASSWORD_FORMAT 예외 발생") + void execute_withInvalidNewPasswordFormat_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto("currentPass1!", "short"); + given(passwordEncoder.matches("currentPass1!", "encodedCurrentPassword")).willReturn(true); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PASSWORD_FORMAT); + then(user).shouldHaveNoMoreInteractions(); + } + } + + @Nested + @DisplayName("비밀번호가 없는 소셜 전용 사용자") + class SocialOnlyUser { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn(null); + } + + @Test + @DisplayName("currentPassword 없이 유효한 새 비밀번호만으로 설정 성공") + void execute_withoutCurrentPassword_succeeds() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto(null, "newPass1!"); + given(passwordEncoder.encode("newPass1!")).willReturn("encodedNewPassword"); + + // when & then + assertThatNoException().isThrownBy(() -> updatePassword.execute(actor, request)); + then(user).should().updatePassword("encodedNewPassword"); + then(passwordEncoder).should(org.mockito.Mockito.never()).matches(anyString(), anyString()); + } + + @Test + @DisplayName("유효하지 않은 새 비밀번호 형식으로 설정 시 INVALID_PASSWORD_FORMAT 예외 발생") + void execute_withInvalidNewPasswordFormat_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto(null, "weak"); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PASSWORD_FORMAT); + then(user).shouldHaveNoMoreInteractions(); + } + } +} From 03ca57de8405747df602f1494fd7a09f18b96156 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Tue, 14 Apr 2026 12:41:31 +0900 Subject: [PATCH 08/19] =?UTF-8?q?refactor:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?API=20=EA=B2=BD=EB=A1=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserSocialController.java | 7 ++++--- .../controller/UserSocialControllerSpec.java | 5 +++-- .../UserSocialQueryRepositoryImpl.java | 18 +++++++++++++++--- .../user/usecase/UnlinkSocialAccount.java | 2 +- .../outbound/UserSocialQueryRepository.java | 2 ++ .../user/usecase/UnlinkSocialAccountTests.java | 10 +++++++--- 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java index 0bb98b52..21663224 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java @@ -7,6 +7,7 @@ import com.dreamteam.alter.domain.user.context.AppActor; import com.dreamteam.alter.domain.user.port.inbound.LinkSocialAccountUseCase; import com.dreamteam.alter.domain.user.port.inbound.UnlinkSocialAccountUseCase; +import com.dreamteam.alter.domain.user.type.SocialProvider; import jakarta.annotation.Resource; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -35,12 +36,12 @@ public ResponseEntity> linkSocialAccount( } @Override - @DeleteMapping("/unlink") + @DeleteMapping("/unlink/{provider}") public ResponseEntity> unlinkSocialAccount( - @Valid @RequestBody UnlinkSocialAccountRequestDto request + @PathVariable SocialProvider provider ) { AppActor actor = AppActionContext.getInstance().getActor(); - unlinkSocialAccount.execute(actor, request); + unlinkSocialAccount.execute(actor, new UnlinkSocialAccountRequestDto(provider)); return ResponseEntity.ok(CommonApiResponse.empty()); } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java index 8cf8331e..84203cfa 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java @@ -2,7 +2,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto; -import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.domain.user.type.SocialProvider; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; @Tag(name = "사용자 - 소셜 계정 연동") public interface UserSocialControllerSpec { @@ -60,5 +61,5 @@ public interface UserSocialControllerSpec { } )) }) - ResponseEntity> unlinkSocialAccount(@Valid UnlinkSocialAccountRequestDto request); + ResponseEntity> unlinkSocialAccount(@PathVariable SocialProvider provider); } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java index 4912ca45..74c0a54e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java @@ -7,9 +7,11 @@ import com.dreamteam.alter.domain.user.type.SocialProvider; import com.dreamteam.alter.domain.user.type.UserStatus; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -27,7 +29,7 @@ public Optional findByUserIdAndSocialProvider(Long userId, SocialPro qUserSocial.user.id.eq(userId), qUserSocial.socialProvider.eq(socialProvider) ) - .fetchOne(); + .fetchFirst(); return Optional.ofNullable(userSocial); } @@ -80,7 +82,7 @@ public boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, @Override public boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialProvider) { QUserSocial qUserSocial = QUserSocial.userSocial; - + Long count = queryFactory.select(qUserSocial.count()) .from(qUserSocial) .where( @@ -89,7 +91,17 @@ public boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialP qUserSocial.socialProvider.eq(socialProvider) ) .fetchOne(); - + return count != null && count > 0; } + + @Override + public long countByUserIdForUpdate(Long userId) { + QUserSocial q = QUserSocial.userSocial; + List rows = queryFactory.selectFrom(q) + .where(q.user.id.eq(userId)) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .fetch(); + return rows.size(); + } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java index e46153ac..6bef9fe0 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java @@ -31,7 +31,7 @@ public void execute(AppActor actor, UnlinkSocialAccountRequestDto request) { .orElseThrow(() -> new CustomException(ErrorCode.SOCIAL_ACCOUNT_NOT_LINKED)); if (ObjectUtils.isEmpty(user.getPassword())) { - long count = userSocialQueryRepository.countByUserId(user.getId()); + long count = userSocialQueryRepository.countByUserIdForUpdate(user.getId()); if (count <= 1) { throw new CustomException(ErrorCode.SOCIAL_UNLINK_NOT_ALLOWED); } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java index 2257fc71..5b379fbf 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java @@ -16,4 +16,6 @@ public interface UserSocialQueryRepository { boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialProvider); long countByUserId(Long userId); + + long countByUserIdForUpdate(Long userId); } diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java index ca98b99d..e754ecc8 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java @@ -22,9 +22,11 @@ import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("UnlinkSocialAccount 테스트") @@ -86,14 +88,16 @@ void execute_withLinkedSocial_succeeds() { // when & then assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); then(userSocialRepository).should().delete(userSocial); + then(userSocialQueryRepository).should(never()).countByUserIdForUpdate(any()); } @Test - @DisplayName("소셜 계정이 1개뿐이어도 해제 가능하다 (제약은 비밀번호 없는 사용자에만 적용)") + @DisplayName("소셜 계정이 1개뿐이어도 비밀번호 있는 사용자는 해제 가능하다") void execute_withSingleSocial_succeeds() { // when & then assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); then(userSocialRepository).should().delete(userSocial); + then(userSocialQueryRepository).should(never()).countByUserIdForUpdate(any()); } } @@ -112,7 +116,7 @@ void setUp() { @DisplayName("소셜 계정이 2개일 때 1개 해제 성공") void execute_withMultipleSocials_succeeds() { // given - given(userSocialQueryRepository.countByUserId(1L)).willReturn(2L); + given(userSocialQueryRepository.countByUserIdForUpdate(1L)).willReturn(2L); // when & then assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); @@ -123,7 +127,7 @@ void execute_withMultipleSocials_succeeds() { @DisplayName("마지막 소셜 계정(1개) 해제 시도 시 SOCIAL_UNLINK_NOT_ALLOWED 예외 발생") void execute_withSingleSocial_throwsException() { // given - given(userSocialQueryRepository.countByUserId(1L)).willReturn(1L); + given(userSocialQueryRepository.countByUserIdForUpdate(1L)).willReturn(1L); // when & then assertThatThrownBy(() -> unlinkSocialAccount.execute(actor, request)) From f8bbb9b3f3ea7f71f7bb35f258beff43137ea4e3 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Tue, 14 Apr 2026 12:41:34 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94=20-=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../general/user/dto/UpdatePasswordRequestDto.java | 7 +++++++ .../application/user/usecase/LoginWithPassword.java | 11 ++++++----- .../application/user/usecase/UpdatePasswordTests.java | 9 +++++---- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java index 9e66b2a2..718f623f 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.adapter.inbound.general.user.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; @@ -20,4 +21,10 @@ public class UpdatePasswordRequestDto { @Size(min = 8, max = 16) @Schema(description = "새 비밀번호 (8~16자, 영문·숫자·특수문자 각 1개 이상)", example = "newPass1!") private String newPassword; + + @AssertTrue(message = "새 비밀번호는 현재 비밀번호와 달라야 합니다") + private boolean isNewPasswordDifferent() { + if (currentPassword == null || newPassword == null) return true; + return !currentPassword.equals(newPassword); + } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java index d8075fb3..3bdcd242 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java @@ -1,22 +1,23 @@ package com.dreamteam.alter.application.user.usecase; -import com.dreamteam.alter.adapter.inbound.general.user.dto.LoginWithPasswordRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; -import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; -import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.adapter.inbound.general.user.dto.LoginWithPasswordRequestDto; +import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; -import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.domain.auth.entity.AuthLog; import com.dreamteam.alter.domain.auth.entity.Authorization; import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; import com.dreamteam.alter.domain.auth.type.AuthLogType; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; @Service("loginWithPassword") @RequiredArgsConstructor @@ -33,7 +34,7 @@ public GenerateTokenResponseDto execute(LoginWithPasswordRequestDto request) { User user = userQueryRepository.findByContact(request.getContact()) .orElseThrow(() -> new CustomException(ErrorCode.INVALID_LOGIN_INFO)); - if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + if (ObjectUtils.isEmpty(user.getPassword()) || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new CustomException(ErrorCode.INVALID_LOGIN_INFO); } diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java index 038368e1..26a1dd19 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java @@ -21,6 +21,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("UpdatePassword 테스트") @@ -75,7 +76,7 @@ void execute_withWrongCurrentPassword_throwsException() { assertThatThrownBy(() -> updatePassword.execute(actor, request)) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_CURRENT_PASSWORD); - then(user).shouldHaveNoMoreInteractions(); + then(user).should(never()).updatePassword(anyString()); } @Test @@ -88,7 +89,7 @@ void execute_withNullCurrentPassword_throwsException() { assertThatThrownBy(() -> updatePassword.execute(actor, request)) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_CURRENT_PASSWORD); - then(user).shouldHaveNoMoreInteractions(); + then(user).should(never()).updatePassword(anyString()); } @Test @@ -102,7 +103,7 @@ void execute_withInvalidNewPasswordFormat_throwsException() { assertThatThrownBy(() -> updatePassword.execute(actor, request)) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PASSWORD_FORMAT); - then(user).shouldHaveNoMoreInteractions(); + then(user).should(never()).updatePassword(anyString()); } } @@ -138,7 +139,7 @@ void execute_withInvalidNewPasswordFormat_throwsException() { assertThatThrownBy(() -> updatePassword.execute(actor, request)) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PASSWORD_FORMAT); - then(user).shouldHaveNoMoreInteractions(); + then(user).should(never()).updatePassword(anyString()); } } } From 78b5014611ed9f8172077431071b3c57abc95e3b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Tue, 14 Apr 2026 12:41:37 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=84=B8=EC=85=98=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EA=B4=80=EB=A6=AC=EC=9A=A9=20Repository=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CreateUserWithSocialRequestDto.java | 12 +++ .../SignupSessionCacheRepository.java | 40 ++++++++ .../user/usecase/CreateUserWithSocial.java | 21 +++-- .../usecase/CreateUserWithSocialTests.java | 92 +++++++------------ 4 files changed, 95 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java index 1c09c574..08bcdf20 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java @@ -5,6 +5,7 @@ import com.dreamteam.alter.domain.user.type.UserGender; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -57,4 +58,15 @@ public class CreateUserWithSocialRequestDto { @Schema(description = "생년월일", example = "YYYYMMDD") private String birthday; + @AssertTrue(message = "WEB 플랫폼은 authorizationCode가 필수입니다") + private boolean isWebPlatformValid() { + if (platformType != PlatformType.WEB) return true; + return authorizationCode != null && !authorizationCode.isBlank(); + } + + @AssertTrue(message = "NATIVE 플랫폼은 oauthToken이 필수입니다") + private boolean isNativePlatformValid() { + if (platformType != PlatformType.NATIVE) return true; + return oauthToken != null; + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java new file mode 100644 index 00000000..1ab77f00 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java @@ -0,0 +1,40 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SignupSessionCacheRepository { + + private final StringRedisTemplate redisTemplate; + + public void save(String key, String value) { + redisTemplate.opsForValue().set(key, value); + } + + public void save(String key, String value, Duration ttl) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + public String get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void delete(String key) { + redisTemplate.delete(key); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteAll(List keys) { + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index c0dd6807..96fa4c04 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -4,6 +4,7 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.SocialLoginRequestDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.common.exception.CustomException; @@ -21,9 +22,10 @@ import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; +import java.util.List; @Service("createUserWithSocial") @RequiredArgsConstructor @@ -39,14 +41,14 @@ public class CreateUserWithSocial implements CreateUserWithSocialUseCase { private final SocialAuthenticationManager socialAuthenticationManager; private final AuthService authService; private final AuthLogRepository authLogRepository; - private final StringRedisTemplate redisTemplate; + private final SignupSessionCacheRepository cacheRepository; @Override public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { // Redis 세션에서 휴대폰 인증 정보 확인 String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); - String contact = redisTemplate.opsForValue().get(sessionIdKey); + String contact = cacheRepository.get(sessionIdKey); if (ObjectUtils.isEmpty(contact)) { throw new CustomException(ErrorCode.SIGNUP_SESSION_NOT_EXIST); @@ -98,8 +100,8 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) user.addUserSocial(userSocial); // 회원가입 세션 삭제 - redisTemplate.delete(sessionIdKey); - redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + String contactKey = CONTACT_INDEX_KEY_PREFIX + contact; + cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); @@ -108,17 +110,18 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) } private void validateDuplication(CreateUserWithSocialRequestDto request, String contact, String sessionIdKey) { + String contactKey = CONTACT_INDEX_KEY_PREFIX + contact; + List keysToDelete = Arrays.asList(sessionIdKey, contactKey); + // 닉네임 중복 확인 if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { - redisTemplate.delete(sessionIdKey); - redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + cacheRepository.deleteAll(keysToDelete); throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); } // 연락처 중복 확인 if (userQueryRepository.findByContact(contact).isPresent()) { - redisTemplate.delete(sessionIdKey); - redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + cacheRepository.deleteAll(keysToDelete); throw new CustomException(ErrorCode.USER_CONTACT_DUPLICATED); } } diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 3905509c..58e96500 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.outbound.cache.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.common.exception.CustomException; @@ -25,14 +26,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -62,15 +62,11 @@ class CreateUserWithSocialTests { private AuthLogRepository authLogRepository; @Mock - private StringRedisTemplate redisTemplate; + private SignupSessionCacheRepository cacheRepository; @InjectMocks private CreateUserWithSocial createUserWithSocial; - @Mock - @SuppressWarnings("unchecked") - private ValueOperations valueOperations; - private CreateUserWithSocialRequestDto request; @BeforeEach @@ -86,40 +82,14 @@ void setUp() { UserGender.GENDER_MALE, "19900101" ); - - given(redisTemplate.opsForValue()).willReturn(valueOperations); - } - - private SocialAuthInfo createSocialAuthInfo() { - SocialAuthInfo authInfo = mock(SocialAuthInfo.class); - given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); - given(authInfo.getSocialId()).willReturn("kakao-social-id"); - given(authInfo.getEmail()).willReturn("social@example.com"); - given(authInfo.getRefreshToken()).willReturn("kakao-refresh-token"); - return authInfo; - } - - private SocialAuthInfo createSocialAuthInfoForDuplicateSocialId() { - SocialAuthInfo authInfo = mock(SocialAuthInfo.class); - given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); - given(authInfo.getSocialId()).willReturn("kakao-social-id"); - return authInfo; - } - - private SocialAuthInfo createSocialAuthInfoForDuplicateEmail() { - SocialAuthInfo authInfo = mock(SocialAuthInfo.class); - given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); - given(authInfo.getSocialId()).willReturn("kakao-social-id"); - given(authInfo.getEmail()).willReturn("social@example.com"); - return authInfo; } - private SocialAuthInfo createSocialAuthInfoWithoutEmail() { + private SocialAuthInfo createSocialAuthInfo(String socialId, String email, String refreshToken) { SocialAuthInfo authInfo = mock(SocialAuthInfo.class); given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); - given(authInfo.getSocialId()).willReturn("kakao-social-id"); - given(authInfo.getEmail()).willReturn(null); - given(authInfo.getRefreshToken()).willReturn("kakao-refresh-token"); + given(authInfo.getSocialId()).willReturn(socialId); + if (email != null) given(authInfo.getEmail()).willReturn(email); + if (refreshToken != null) given(authInfo.getRefreshToken()).willReturn(refreshToken); return authInfo; } @@ -129,9 +99,9 @@ class ExecuteTests { @Test @DisplayName("회원가입 세션이 존재하지 않을 경우 SIGNUP_SESSION_NOT_EXIST 예외 발생") - void fails_whenSignupSessionNotFound() { + void execute_signupSessionNotFound_throwsSignupSessionNotExist() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn(null); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn(null); // when & then assertThatThrownBy(() -> createUserWithSocial.execute(request)) @@ -144,9 +114,9 @@ void fails_whenSignupSessionNotFound() { @Test @DisplayName("닉네임이 중복될 경우 NICKNAME_DUPLICATED 예외 발생 및 Redis 세션 삭제") - void fails_whenNicknameDuplicated() { + void execute_nicknameDuplicated_throwsNicknameDuplicated() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.of(mock(User.class))); // when & then @@ -155,16 +125,15 @@ void fails_whenNicknameDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); - then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); - then(redisTemplate).should().delete("SIGNUP:CONTACT:01012345678"); + then(cacheRepository).should().deleteAll(anyList()); then(userRepository).should(never()).save(any()); } @Test @DisplayName("연락처가 중복될 경우 USER_CONTACT_DUPLICATED 예외 발생 및 Redis 세션 삭제") - void fails_whenContactDuplicated() { + void execute_contactDuplicated_throwsUserContactDuplicated() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.of(mock(User.class))); @@ -174,20 +143,19 @@ void fails_whenContactDuplicated() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); - then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); - then(redisTemplate).should().delete("SIGNUP:CONTACT:01012345678"); + then(cacheRepository).should().deleteAll(anyList()); then(userRepository).should(never()).save(any()); } @Test @DisplayName("이미 등록된 소셜 계정일 경우 SOCIAL_ID_DUPLICATED 예외 발생") - void fails_whenSocialIdAlreadyRegistered() { + void execute_socialIdAlreadyRegistered_throwsSocialIdDuplicated() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfoForDuplicateSocialId(); + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(true); @@ -198,18 +166,19 @@ void fails_whenSocialIdAlreadyRegistered() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.SOCIAL_ID_DUPLICATED)); + then(cacheRepository).should(never()).deleteAll(anyList()); then(userRepository).should(never()).save(any()); } @Test @DisplayName("소셜 계정 이메일이 이미 가입된 경우 EMAIL_DUPLICATED 예외 발생") - void fails_whenSocialEmailAlreadyExists() { + void execute_socialEmailAlreadyExists_throwsEmailDuplicated() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfoForDuplicateEmail(); + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", "social@example.com", null); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(false); @@ -221,18 +190,19 @@ void fails_whenSocialEmailAlreadyExists() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.EMAIL_DUPLICATED)); + then(cacheRepository).should(never()).deleteAll(anyList()); then(userRepository).should(never()).save(any()); } @Test @DisplayName("유효한 입력으로 소셜 회원가입 성공 - 소셜 계정 이메일 자동 저장") - void succeeds_withValidSocialSignup() { + void execute_withValidInput_succeeds() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfo(); + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", "social@example.com", "kakao-refresh-token"); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(false); @@ -253,19 +223,18 @@ void succeeds_withValidSocialSignup() { then(userQueryRepository).should().findByEmail("social@example.com"); then(authService).should().generateAuthorization(savedUser, TokenScope.APP); then(authLogRepository).should().save(any()); - then(redisTemplate).should().delete("SIGNUP:PENDING:signup-session-id"); - then(redisTemplate).should().delete("SIGNUP:CONTACT:01012345678"); + then(cacheRepository).should().deleteAll(anyList()); } @Test @DisplayName("소셜 계정에 이메일이 없을 경우 이메일 없이 회원가입 성공") - void succeeds_withNoEmailFromSocialAccount() { + void execute_withNoEmailFromSocial_succeeds() { // given - given(valueOperations.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfoWithoutEmail(); + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, "kakao-refresh-token"); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) .willReturn(false); @@ -283,6 +252,7 @@ void succeeds_withNoEmailFromSocialAccount() { assertThat(result).isNotNull(); then(userRepository).should().save(any(User.class)); then(userQueryRepository).should(never()).findByEmail(any()); + then(cacheRepository).should().deleteAll(anyList()); } } } From e461bbc9f8746fab98189715ad88c1989b4d8217 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Tue, 14 Apr 2026 12:46:43 +0900 Subject: [PATCH 11/19] =?UTF-8?q?test:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/user/usecase/CreateUserWithSocialTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 58e96500..2e2555e3 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -3,7 +3,7 @@ import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; -import com.dreamteam.alter.adapter.outbound.cache.SignupSessionCacheRepository; +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.common.exception.CustomException; From 259c7f21cdc735be61ff8917ec339c75167f77d2 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 19:32:00 +0900 Subject: [PATCH 12/19] =?UTF-8?q?refactor:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/persistence/SignupSessionCacheRepository.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java index 1ab77f00..535a677e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java @@ -3,8 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.List; @@ -26,12 +24,10 @@ public String get(String key) { return redisTemplate.opsForValue().get(key); } - @Transactional(propagation = Propagation.REQUIRES_NEW) public void delete(String key) { redisTemplate.delete(key); } - @Transactional(propagation = Propagation.REQUIRES_NEW) public void deleteAll(List keys) { if (!keys.isEmpty()) { redisTemplate.delete(keys); From b2b993cea0a647d86ca3fae61d3a32336d5879ad Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 19:32:08 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=B8=EC=85=98=20=EC=A0=95=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/event/SignupCompletedEvent.java | 11 ++++++++ .../event/SignupSessionCleanupListener.java | 26 +++++++++++++++++++ .../user/usecase/CreateUserWithSocial.java | 8 +++--- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java diff --git a/src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java b/src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java new file mode 100644 index 00000000..a07acaaa --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.application.user.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SignupCompletedEvent { + private String signupSessionId; + private String contact; +} diff --git a/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java b/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java new file mode 100644 index 00000000..646d1f05 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java @@ -0,0 +1,26 @@ +package com.dreamteam.alter.application.user.event; + +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class SignupSessionCleanupListener { + + private static final String KEY_PREFIX = "SIGNUP:PENDING:"; + private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; + + private final SignupSessionCacheRepository cacheRepository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleSignupCompleted(SignupCompletedEvent event) { + String sessionKey = KEY_PREFIX + event.getSignupSessionId(); + String contactKey = CONTACT_INDEX_KEY_PREFIX + event.getContact(); + cacheRepository.deleteAll(List.of(sessionKey, contactKey)); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index 96fa4c04..68bb23a3 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -7,6 +7,7 @@ import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.application.user.event.SignupCompletedEvent; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.entity.AuthLog; @@ -22,6 +23,7 @@ import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; @@ -42,6 +44,7 @@ public class CreateUserWithSocial implements CreateUserWithSocialUseCase { private final AuthService authService; private final AuthLogRepository authLogRepository; private final SignupSessionCacheRepository cacheRepository; + private final ApplicationEventPublisher eventPublisher; @Override public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { @@ -99,9 +102,8 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) ); user.addUserSocial(userSocial); - // 회원가입 세션 삭제 - String contactKey = CONTACT_INDEX_KEY_PREFIX + contact; - cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); + // 회원가입 세션 삭제 (커밋 후 이벤트로 처리) + eventPublisher.publishEvent(new SignupCompletedEvent(request.getSignupSessionId(), contact)); Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); From f8761ed8965431e2fda7331e40eed9c1b561848b Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 20:02:50 +0900 Subject: [PATCH 14/19] =?UTF-8?q?test:=20CreateUserWithSocial=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20-=20ApplicationEventPublisher=20=EB=AA=A8?= =?UTF-8?q?=ED=82=B9=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/usecase/CreateUserWithSocialTests.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index 2e2555e3..ac16e1d0 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -6,8 +6,10 @@ import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.application.user.event.SignupCompletedEvent; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.AuthLog; import com.dreamteam.alter.domain.auth.entity.Authorization; import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; import com.dreamteam.alter.domain.auth.type.TokenScope; @@ -26,6 +28,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.util.Optional; @@ -34,6 +37,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -64,6 +68,9 @@ class CreateUserWithSocialTests { @Mock private SignupSessionCacheRepository cacheRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private CreateUserWithSocial createUserWithSocial; @@ -213,6 +220,7 @@ void execute_withValidInput_succeeds() { Authorization authorization = mock(Authorization.class); given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); + given(authLogRepository.save(any())).willReturn(mock(AuthLog.class)); // when GenerateTokenResponseDto result = createUserWithSocial.execute(request); @@ -223,7 +231,7 @@ void execute_withValidInput_succeeds() { then(userQueryRepository).should().findByEmail("social@example.com"); then(authService).should().generateAuthorization(savedUser, TokenScope.APP); then(authLogRepository).should().save(any()); - then(cacheRepository).should().deleteAll(anyList()); + then(eventPublisher).should().publishEvent(isA(SignupCompletedEvent.class)); } @Test @@ -244,6 +252,7 @@ void execute_withNoEmailFromSocial_succeeds() { Authorization authorization = mock(Authorization.class); given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); + given(authLogRepository.save(any())).willReturn(mock(AuthLog.class)); // when GenerateTokenResponseDto result = createUserWithSocial.execute(request); @@ -252,7 +261,7 @@ void execute_withNoEmailFromSocial_succeeds() { assertThat(result).isNotNull(); then(userRepository).should().save(any(User.class)); then(userQueryRepository).should(never()).findByEmail(any()); - then(cacheRepository).should().deleteAll(anyList()); + then(eventPublisher).should().publishEvent(isA(SignupCompletedEvent.class)); } } } From bb6006e6e596b42fba7b0f54a963a0c6b0851a07 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 20:50:49 +0900 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=B8=EC=85=98=20=EC=83=81=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/event/SignupSessionCleanupListener.java | 8 +++----- .../user/usecase/CreateSignupSession.java | 15 ++++++--------- .../user/usecase/CreateUserWithSocial.java | 8 +++----- .../common/constants/SignupSessionConstants.java | 13 +++++++++++++ 4 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/common/constants/SignupSessionConstants.java diff --git a/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java b/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java index 646d1f05..e60b0b7d 100644 --- a/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java +++ b/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.application.user.event; import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; +import com.dreamteam.alter.common.constants.SignupSessionConstants; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -12,15 +13,12 @@ @RequiredArgsConstructor public class SignupSessionCleanupListener { - private static final String KEY_PREFIX = "SIGNUP:PENDING:"; - private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; - private final SignupSessionCacheRepository cacheRepository; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleSignupCompleted(SignupCompletedEvent event) { - String sessionKey = KEY_PREFIX + event.getSignupSessionId(); - String contactKey = CONTACT_INDEX_KEY_PREFIX + event.getContact(); + String sessionKey = SignupSessionConstants.Session.KEY_PREFIX + event.getSignupSessionId(); + String contactKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + event.getContact(); cacheRepository.deleteAll(List.of(sessionKey, contactKey)); } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateSignupSession.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateSignupSession.java index 07b00c9f..1e27daba 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateSignupSession.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateSignupSession.java @@ -2,6 +2,7 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateSignupSessionRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateSignupSessionResponseDto; +import com.dreamteam.alter.common.constants.SignupSessionConstants; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.util.PhoneNumberUtil; @@ -22,10 +23,6 @@ @Transactional public class CreateSignupSession implements CreateSignupSessionUseCase { - private static final String SESSION_KEY_PREFIX = "SIGNUP:PENDING:"; - private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; - private static final long SESSION_EXPIRATION_MINUTES = 10; // 10분 후 만료 - private final UserQueryRepository userQueryRepository; private final StringRedisTemplate redisTemplate; private final FirebaseTokenVerifier firebaseTokenVerifier; @@ -42,24 +39,24 @@ public CreateSignupSessionResponseDto execute(CreateSignupSessionRequestDto requ } // 기존 세션 확인 및 삭제 - String contactIndexKey = CONTACT_INDEX_KEY_PREFIX + contact; + String contactIndexKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + contact; String existingSessionId = redisTemplate.opsForValue().get(contactIndexKey); if (ObjectUtils.isNotEmpty(existingSessionId)) { - String existingSessionKey = SESSION_KEY_PREFIX + existingSessionId; + String existingSessionKey = SignupSessionConstants.Session.KEY_PREFIX + existingSessionId; redisTemplate.delete(existingSessionKey); redisTemplate.delete(contactIndexKey); } // 회원가입 세션 생성 String signupSessionId = UUID.randomUUID().toString(); - String sessionKey = SESSION_KEY_PREFIX + signupSessionId; + String sessionKey = SignupSessionConstants.Session.KEY_PREFIX + signupSessionId; // 세션 키 저장: 세션 ID -> 전화번호 (10분 후 만료) redisTemplate.opsForValue().set( sessionKey, contact, - SESSION_EXPIRATION_MINUTES, + SignupSessionConstants.Session.EXPIRATION_MINUTES, TimeUnit.MINUTES ); @@ -67,7 +64,7 @@ public CreateSignupSessionResponseDto execute(CreateSignupSessionRequestDto requ redisTemplate.opsForValue().set( contactIndexKey, signupSessionId, - SESSION_EXPIRATION_MINUTES, + SignupSessionConstants.Session.EXPIRATION_MINUTES, TimeUnit.MINUTES ); diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index 68bb23a3..91645042 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -8,6 +8,7 @@ import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.application.user.event.SignupCompletedEvent; +import com.dreamteam.alter.common.constants.SignupSessionConstants; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.entity.AuthLog; @@ -34,9 +35,6 @@ @Transactional public class CreateUserWithSocial implements CreateUserWithSocialUseCase { - private static final String KEY_PREFIX = "SIGNUP:PENDING:"; - private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; - private final UserRepository userRepository; private final UserQueryRepository userQueryRepository; private final UserSocialQueryRepository userSocialQueryRepository; @@ -50,7 +48,7 @@ public class CreateUserWithSocial implements CreateUserWithSocialUseCase { public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { // Redis 세션에서 휴대폰 인증 정보 확인 - String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); + String sessionIdKey = SignupSessionConstants.Session.KEY_PREFIX + request.getSignupSessionId(); String contact = cacheRepository.get(sessionIdKey); if (ObjectUtils.isEmpty(contact)) { @@ -112,7 +110,7 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) } private void validateDuplication(CreateUserWithSocialRequestDto request, String contact, String sessionIdKey) { - String contactKey = CONTACT_INDEX_KEY_PREFIX + contact; + String contactKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + contact; List keysToDelete = Arrays.asList(sessionIdKey, contactKey); // 닉네임 중복 확인 diff --git a/src/main/java/com/dreamteam/alter/common/constants/SignupSessionConstants.java b/src/main/java/com/dreamteam/alter/common/constants/SignupSessionConstants.java new file mode 100644 index 00000000..f1ee7f95 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/common/constants/SignupSessionConstants.java @@ -0,0 +1,13 @@ +package com.dreamteam.alter.common.constants; + +public final class SignupSessionConstants { + + /** + * 회원가입 세션 관련 상수 + */ + public static final class Session { + public static final String KEY_PREFIX = "SIGNUP:PENDING:"; + public static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; + public static final long EXPIRATION_MINUTES = 10; // 10분 후 만료 + } +} From 3dc955783ccb2712b55379f5358f7d486d5a4fb1 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 22:07:25 +0900 Subject: [PATCH 16/19] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=84=B8=EC=85=98=20=EC=B2=98=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/event/SignupCompletedEvent.java | 11 --- .../event/SignupSessionCleanupListener.java | 24 ----- .../user/usecase/CreateUserWithSocial.java | 59 ++++-------- .../user/usecase/CreateUserWithSocialTx.java | 58 ++++++++++++ .../usecase/CreateUserWithSocialTests.java | 89 ++++--------------- 5 files changed, 90 insertions(+), 151 deletions(-) delete mode 100644 src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java delete mode 100644 src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java diff --git a/src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java b/src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java deleted file mode 100644 index a07acaaa..00000000 --- a/src/main/java/com/dreamteam/alter/application/user/event/SignupCompletedEvent.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.dreamteam.alter.application.user.event; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class SignupCompletedEvent { - private String signupSessionId; - private String contact; -} diff --git a/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java b/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java deleted file mode 100644 index e60b0b7d..00000000 --- a/src/main/java/com/dreamteam/alter/application/user/event/SignupSessionCleanupListener.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.dreamteam.alter.application.user.event; - -import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; -import com.dreamteam.alter.common.constants.SignupSessionConstants; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import java.util.List; - -@Component -@RequiredArgsConstructor -public class SignupSessionCleanupListener { - - private final SignupSessionCacheRepository cacheRepository; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleSignupCompleted(SignupCompletedEvent event) { - String sessionKey = SignupSessionConstants.Session.KEY_PREFIX + event.getSignupSessionId(); - String contactKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + event.getContact(); - cacheRepository.deleteAll(List.of(sessionKey, contactKey)); - } -} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java index 91645042..dc00b72b 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -6,43 +6,28 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.SocialLoginRequestDto; import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; -import com.dreamteam.alter.application.auth.service.AuthService; -import com.dreamteam.alter.application.user.event.SignupCompletedEvent; import com.dreamteam.alter.common.constants.SignupSessionConstants; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; -import com.dreamteam.alter.domain.auth.entity.AuthLog; -import com.dreamteam.alter.domain.auth.entity.Authorization; -import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; -import com.dreamteam.alter.domain.auth.type.AuthLogType; -import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.user.entity.User; -import com.dreamteam.alter.domain.user.entity.UserSocial; import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; -import com.dreamteam.alter.domain.user.port.outbound.UserRepository; import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; + import java.util.Arrays; import java.util.List; @Service("createUserWithSocial") @RequiredArgsConstructor -@Transactional public class CreateUserWithSocial implements CreateUserWithSocialUseCase { - private final UserRepository userRepository; private final UserQueryRepository userQueryRepository; private final UserSocialQueryRepository userSocialQueryRepository; private final SocialAuthenticationManager socialAuthenticationManager; - private final AuthService authService; - private final AuthLogRepository authLogRepository; private final SignupSessionCacheRepository cacheRepository; - private final ApplicationEventPublisher eventPublisher; + private final CreateUserWithSocialTx createUserWithSocialTx; @Override public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { @@ -78,35 +63,19 @@ public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) String email = socialAuthInfo.getEmail(); if (ObjectUtils.isNotEmpty(email)) { userQueryRepository.findByEmail(email) - .ifPresent(existing -> { throw new CustomException(ErrorCode.EMAIL_DUPLICATED); }); + .ifPresent(existing -> { + throw new CustomException(ErrorCode.EMAIL_DUPLICATED); + }); } - // 사용자 생성 - User user = userRepository.save(User.createWithSocial( - contact, - request.getName(), - request.getNickname(), - request.getGender(), - request.getBirthday(), - email - )); - - // 소셜 계정 연동 - UserSocial userSocial = UserSocial.create( - user, - socialAuthInfo.getProvider(), - socialAuthInfo.getSocialId(), - socialAuthInfo.getRefreshToken() - ); - user.addUserSocial(userSocial); - - // 회원가입 세션 삭제 (커밋 후 이벤트로 처리) - eventPublisher.publishEvent(new SignupCompletedEvent(request.getSignupSessionId(), contact)); + // 사용자 및 소셜 계정 엔티티 저장 + GenerateTokenResponseDto response = createUserWithSocialTx.process(contact, request, socialAuthInfo); - Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); - authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); + // 회원가입 세션 삭제 + String contactKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + contact; + cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); - return GenerateTokenResponseDto.of(authorization); + return response; } private void validateDuplication(CreateUserWithSocialRequestDto request, String contact, String sessionIdKey) { @@ -114,13 +83,15 @@ private void validateDuplication(CreateUserWithSocialRequestDto request, String List keysToDelete = Arrays.asList(sessionIdKey, contactKey); // 닉네임 중복 확인 - if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { + if (userQueryRepository.findByNickname(request.getNickname()) + .isPresent()) { cacheRepository.deleteAll(keysToDelete); throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); } // 연락처 중복 확인 - if (userQueryRepository.findByContact(contact).isPresent()) { + if (userQueryRepository.findByContact(contact) + .isPresent()) { cacheRepository.deleteAll(keysToDelete); throw new CustomException(ErrorCode.USER_CONTACT_DUPLICATED); } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java new file mode 100644 index 00000000..7d0a05e9 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTx.java @@ -0,0 +1,58 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.domain.auth.entity.AuthLog; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; +import com.dreamteam.alter.domain.auth.type.AuthLogType; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.outbound.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CreateUserWithSocialTx { + + private final UserRepository userRepository; + private final AuthService authService; + private final AuthLogRepository authLogRepository; + + @Transactional + public GenerateTokenResponseDto process( + String contact, + CreateUserWithSocialRequestDto request, + SocialAuthInfo socialAuthInfo + ) { + // 사용자 생성 + User user = userRepository.save(User.createWithSocial( + contact, + request.getName(), + request.getNickname(), + request.getGender(), + request.getBirthday(), + socialAuthInfo.getEmail() + )); + + // 소셜 계정 연동 + UserSocial userSocial = UserSocial.create( + user, + socialAuthInfo.getProvider(), + socialAuthInfo.getSocialId(), + socialAuthInfo.getRefreshToken() + ); + user.addUserSocial(userSocial); + + // Authorization, AuthLog 저장 + Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); + authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); + + return GenerateTokenResponseDto.of(authorization); + } +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java index ac16e1d0..0c51b176 100644 --- a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -5,17 +5,10 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; -import com.dreamteam.alter.application.auth.service.AuthService; -import com.dreamteam.alter.application.user.event.SignupCompletedEvent; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; -import com.dreamteam.alter.domain.auth.entity.AuthLog; -import com.dreamteam.alter.domain.auth.entity.Authorization; -import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; -import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; -import com.dreamteam.alter.domain.user.port.outbound.UserRepository; import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; import com.dreamteam.alter.domain.user.type.PlatformType; import com.dreamteam.alter.domain.user.type.SocialProvider; @@ -28,7 +21,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; import java.util.Optional; @@ -37,7 +29,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isA; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -47,9 +38,6 @@ @DisplayName("CreateUserWithSocial 테스트") class CreateUserWithSocialTests { - @Mock - private UserRepository userRepository; - @Mock private UserQueryRepository userQueryRepository; @@ -59,17 +47,11 @@ class CreateUserWithSocialTests { @Mock private SocialAuthenticationManager socialAuthenticationManager; - @Mock - private AuthService authService; - - @Mock - private AuthLogRepository authLogRepository; - @Mock private SignupSessionCacheRepository cacheRepository; @Mock - private ApplicationEventPublisher eventPublisher; + private CreateUserWithSocialTx createUserWithSocialTx; @InjectMocks private CreateUserWithSocial createUserWithSocial; @@ -116,7 +98,7 @@ void execute_signupSessionNotFound_throwsSignupSessionNotExist() { .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) .isEqualTo(ErrorCode.SIGNUP_SESSION_NOT_EXIST)); - then(userRepository).should(never()).save(any()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any()); } @Test @@ -133,7 +115,7 @@ void execute_nicknameDuplicated_throwsNicknameDuplicated() { .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); then(cacheRepository).should().deleteAll(anyList()); - then(userRepository).should(never()).save(any()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any()); } @Test @@ -151,7 +133,7 @@ void execute_contactDuplicated_throwsUserContactDuplicated() { .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); then(cacheRepository).should().deleteAll(anyList()); - then(userRepository).should(never()).save(any()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any()); } @Test @@ -174,7 +156,7 @@ void execute_socialIdAlreadyRegistered_throwsSocialIdDuplicated() { .isEqualTo(ErrorCode.SOCIAL_ID_DUPLICATED)); then(cacheRepository).should(never()).deleteAll(anyList()); - then(userRepository).should(never()).save(any()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any()); } @Test @@ -198,70 +180,33 @@ void execute_socialEmailAlreadyExists_throwsEmailDuplicated() { .isEqualTo(ErrorCode.EMAIL_DUPLICATED)); then(cacheRepository).should(never()).deleteAll(anyList()); - then(userRepository).should(never()).save(any()); + then(createUserWithSocialTx).should(never()).process(any(), any(), any()); } @Test - @DisplayName("유효한 입력으로 소셜 회원가입 성공 - 소셜 계정 이메일 자동 저장") + @DisplayName("유효한 입력으로 소셜 회원가입 성공") void execute_withValidInput_succeeds() { - // given - given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); - given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); - given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - - SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", "social@example.com", "kakao-refresh-token"); - given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); - given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) - .willReturn(false); - given(userQueryRepository.findByEmail("social@example.com")).willReturn(Optional.empty()); - - User savedUser = mock(User.class); - given(userRepository.save(any(User.class))).willReturn(savedUser); - - Authorization authorization = mock(Authorization.class); - given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); - given(authLogRepository.save(any())).willReturn(mock(AuthLog.class)); - - // when - GenerateTokenResponseDto result = createUserWithSocial.execute(request); - - // then - assertThat(result).isNotNull(); - then(userRepository).should().save(any(User.class)); - then(userQueryRepository).should().findByEmail("social@example.com"); - then(authService).should().generateAuthorization(savedUser, TokenScope.APP); - then(authLogRepository).should().save(any()); - then(eventPublisher).should().publishEvent(isA(SignupCompletedEvent.class)); - } - - @Test - @DisplayName("소셜 계정에 이메일이 없을 경우 이메일 없이 회원가입 성공") - void execute_withNoEmailFromSocial_succeeds() { - // given + // given - 기본 세션 및 검증 given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); - SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, "kakao-refresh-token"); + // 소셜 인증 및 중복 확인 + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); - given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) - .willReturn(false); - - User savedUser = mock(User.class); - given(userRepository.save(any(User.class))).willReturn(savedUser); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")).willReturn(false); - Authorization authorization = mock(Authorization.class); - given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); - given(authLogRepository.save(any())).willReturn(mock(AuthLog.class)); + // TX 저장 + GenerateTokenResponseDto mockResponse = mock(GenerateTokenResponseDto.class); + given(createUserWithSocialTx.process(any(), any(), any())).willReturn(mockResponse); // when GenerateTokenResponseDto result = createUserWithSocial.execute(request); // then - assertThat(result).isNotNull(); - then(userRepository).should().save(any(User.class)); - then(userQueryRepository).should(never()).findByEmail(any()); - then(eventPublisher).should().publishEvent(isA(SignupCompletedEvent.class)); + assertThat(result).isEqualTo(mockResponse); + then(createUserWithSocialTx).should().process(eq("01012345678"), eq(request), any()); + then(cacheRepository).should().deleteAll(anyList()); } } } From 7c2b54622f254c12161e6bec8b5bb20ade395542 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 22:56:08 +0900 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20CreateUserTx=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/usecase/CreateUserTx.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java new file mode 100644 index 00000000..44d3fb01 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java @@ -0,0 +1,59 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.AuthLog; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; +import com.dreamteam.alter.domain.auth.type.AuthLogType; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CreateUserTx { + + private final UserRepository userRepository; + private final AuthService authService; + private final PasswordEncoder passwordEncoder; + private final AuthLogRepository authLogRepository; + private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + + @Transactional + public GenerateTokenResponseDto process( + CreateUserRequestDto request, + String contact, + String verifiedEmail + ) { + // 사용자 생성 + User user = userRepository.save(User.create( + contact, + passwordEncoder.encode(request.getPassword()), + request.getName(), + request.getNickname(), + request.getGender(), + request.getBirthday(), + verifiedEmail + )); + + // 이메일 인증 세션 삭제 + if (ObjectUtils.isNotEmpty(verifiedEmail)) { + emailVerificationSessionStoreRepository.deleteSession(request.getEmailSessionId()); + } + + Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); + authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); + + return GenerateTokenResponseDto.of(authorization); + } +} From d7ed838fd65217ee5505726eeff654a75132faf7 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 23:07:18 +0900 Subject: [PATCH 18/19] =?UTF-8?q?refactor:=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용되지 않는 메소드 제거 - import 참조 수정 - 쿼리 개선 --- .../SignupSessionCacheRepository.java | 4 ---- .../UserSocialQueryRepositoryImpl.java | 17 +++-------------- .../user/usecase/UnlinkSocialAccount.java | 2 +- .../outbound/UserSocialQueryRepository.java | 2 -- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java index 535a677e..0deace02 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java @@ -12,10 +12,6 @@ public class SignupSessionCacheRepository { private final StringRedisTemplate redisTemplate; - public void save(String key, String value) { - redisTemplate.opsForValue().set(key, value); - } - public void save(String key, String value, Duration ttl) { redisTemplate.opsForValue().set(key, value, ttl); } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java index 74c0a54e..90810e71 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java @@ -34,18 +34,6 @@ public Optional findByUserIdAndSocialProvider(Long userId, SocialPro return Optional.ofNullable(userSocial); } - @Override - public long countByUserId(Long userId) { - QUserSocial qUserSocial = QUserSocial.userSocial; - - Long count = queryFactory.select(qUserSocial.count()) - .from(qUserSocial) - .where(qUserSocial.user.id.eq(userId)) - .fetchOne(); - - return count != null ? count : 0L; - } - @Override public Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId) { QUserSocial qUserSocial = QUserSocial.userSocial; @@ -98,10 +86,11 @@ public boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialP @Override public long countByUserIdForUpdate(Long userId) { QUserSocial q = QUserSocial.userSocial; - List rows = queryFactory.selectFrom(q) + List ids = queryFactory.select(q.id) + .from(q) .where(q.user.id.eq(userId)) .setLockMode(LockModeType.PESSIMISTIC_WRITE) .fetch(); - return rows.size(); + return ids.size(); } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java index 6bef9fe0..768d6bcb 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java @@ -10,9 +10,9 @@ import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.ObjectUtils; @Service("unlinkSocialAccount") @RequiredArgsConstructor diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java index 5b379fbf..0fd8ead8 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java @@ -15,7 +15,5 @@ public interface UserSocialQueryRepository { boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialProvider); - long countByUserId(Long userId); - long countByUserIdForUpdate(Long userId); } From 636ff5ce980d4b69211917ee90ccbd7a4ac191ed Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 15 Apr 2026 23:19:17 +0900 Subject: [PATCH 19/19] =?UTF-8?q?refactor:=20CreateUser=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20CreateUserTx=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=B6=94=EC=83=81=ED=99=94=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/user/usecase/CreateUser.java | 57 ++++++------------- .../user/usecase/CreateUserTx.java | 10 ---- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java index 52d16791..7b13f878 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUser.java @@ -2,49 +2,37 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; -import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; +import com.dreamteam.alter.common.constants.SignupSessionConstants; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.common.util.PasswordValidator; -import com.dreamteam.alter.domain.auth.entity.AuthLog; -import com.dreamteam.alter.domain.auth.entity.Authorization; -import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; -import com.dreamteam.alter.domain.auth.type.AuthLogType; -import com.dreamteam.alter.domain.auth.type.TokenScope; import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; -import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.inbound.CreateUserUseCase; import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; -import com.dreamteam.alter.domain.user.port.outbound.UserRepository; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; + @Service("createUser") @RequiredArgsConstructor @Transactional public class CreateUser implements CreateUserUseCase { - private static final String KEY_PREFIX = "SIGNUP:PENDING:"; - private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; - - private final UserRepository userRepository; private final UserQueryRepository userQueryRepository; - private final AuthService authService; - private final PasswordEncoder passwordEncoder; - private final StringRedisTemplate redisTemplate; - private final AuthLogRepository authLogRepository; + private final SignupSessionCacheRepository cacheRepository; private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; + private final CreateUserTx createUserTx; @Override public GenerateTokenResponseDto execute(CreateUserRequestDto request) { // Redis 세션에서 휴대폰 인증 정보 확인 - String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); - String contact = redisTemplate.opsForValue().get(sessionIdKey); + String sessionIdKey = SignupSessionConstants.Session.KEY_PREFIX + request.getSignupSessionId(); + String contact = cacheRepository.get(sessionIdKey); if (ObjectUtils.isEmpty(contact)) { throw new CustomException(ErrorCode.SIGNUP_SESSION_NOT_EXIST); @@ -61,30 +49,19 @@ public GenerateTokenResponseDto execute(CreateUserRequestDto request) { // 이메일 인증 세션 검증 (선택) String verifiedEmail = resolveVerifiedEmail(request); - // 사용자 생성 - User user = userRepository.save(User.create( - contact, - passwordEncoder.encode(request.getPassword()), - request.getName(), - request.getNickname(), - request.getGender(), - request.getBirthday(), - verifiedEmail - )); + // 사용자 엔티티 저장 + GenerateTokenResponseDto response = createUserTx.process(request, contact, verifiedEmail); // 회원가입 세션 삭제 - redisTemplate.delete(sessionIdKey); - redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + String contactKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + contact; + cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); // 이메일 인증 세션 삭제 if (ObjectUtils.isNotEmpty(verifiedEmail)) { emailVerificationSessionStoreRepository.deleteSession(request.getEmailSessionId()); } - Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); - authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); - - return GenerateTokenResponseDto.of(authorization); + return response; } private String resolveVerifiedEmail(CreateUserRequestDto request) { @@ -106,17 +83,17 @@ private String resolveVerifiedEmail(CreateUserRequestDto request) { } private void validateDuplication(CreateUserRequestDto request, String contact, String sessionIdKey) { + String contactKey = SignupSessionConstants.Session.CONTACT_INDEX_KEY_PREFIX + contact; + // 닉네임 중복 확인 if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { - redisTemplate.delete(sessionIdKey); - redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); } // 연락처 중복 확인 if (userQueryRepository.findByContact(contact).isPresent()) { - redisTemplate.delete(sessionIdKey); - redisTemplate.delete(CONTACT_INDEX_KEY_PREFIX + contact); + cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); throw new CustomException(ErrorCode.USER_CONTACT_DUPLICATED); } } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java index 44d3fb01..ec0604f2 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserTx.java @@ -3,18 +3,14 @@ import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; import com.dreamteam.alter.application.auth.service.AuthService; -import com.dreamteam.alter.common.exception.CustomException; -import com.dreamteam.alter.common.exception.ErrorCode; import com.dreamteam.alter.domain.auth.entity.AuthLog; import com.dreamteam.alter.domain.auth.entity.Authorization; import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; import com.dreamteam.alter.domain.auth.type.AuthLogType; import com.dreamteam.alter.domain.auth.type.TokenScope; -import com.dreamteam.alter.domain.email.port.outbound.EmailVerificationSessionStoreRepository; import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.outbound.UserRepository; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.ObjectUtils; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,7 +23,6 @@ public class CreateUserTx { private final AuthService authService; private final PasswordEncoder passwordEncoder; private final AuthLogRepository authLogRepository; - private final EmailVerificationSessionStoreRepository emailVerificationSessionStoreRepository; @Transactional public GenerateTokenResponseDto process( @@ -46,11 +41,6 @@ public GenerateTokenResponseDto process( verifiedEmail )); - // 이메일 인증 세션 삭제 - if (ObjectUtils.isNotEmpty(verifiedEmail)) { - emailVerificationSessionStoreRepository.deleteSession(request.getEmailSessionId()); - } - Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN));