From b516d5b57230668afde24553c55d470f9351bd26 Mon Sep 17 00:00:00 2001 From: jaejo Date: Sun, 9 Aug 2026 00:31:36 +0900 Subject: [PATCH 1/4] feat: impl BoundedBCryptPasswordEncoder and Redis cache configuration --- .../api/auth/config/SecurityConfig.java | 10 +- .../details/MoMoGoUserDetailsService.java | 31 +++--- .../details/OAuth2UserDetailsService.java | 22 ++--- .../BoundedBCryptPasswordEncoder.java | 94 +++++++++++++++++++ .../momogo/api/common/config/CacheConfig.java | 52 ++++++++++ .../src/main/resources/application.yaml | 5 + momogo-core/build.gradle | 3 +- .../core/common/exception/AuthErrorCode.java | 3 +- .../domain/user/service/UserCacheService.java | 45 +++++++++ .../domain/user/service/UserServiceImpl.java | 17 +++- 10 files changed, 244 insertions(+), 38 deletions(-) create mode 100644 momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java create mode 100644 momogo-api/src/main/java/com/momogo/api/common/config/CacheConfig.java create mode 100644 momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java diff --git a/momogo-api/src/main/java/com/momogo/api/auth/config/SecurityConfig.java b/momogo-api/src/main/java/com/momogo/api/auth/config/SecurityConfig.java index 327878e..a0eafd8 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/config/SecurityConfig.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/config/SecurityConfig.java @@ -25,7 +25,6 @@ import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -42,11 +41,6 @@ @EnableMethodSecurity public class SecurityConfig { - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - @Bean public SecurityFilterChain filterChain( HttpSecurity http, @@ -159,10 +153,10 @@ public WebSecurityCustomizer webSecurityCustomizer() { } @Bean - public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService) { + public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService); - authProvider.setPasswordEncoder(passwordEncoder()); + authProvider.setPasswordEncoder(passwordEncoder); // 비밀번호 검증이 성공적으로 통과된 이후 호출되는 로직 설정 authProvider.setPostAuthenticationChecks(toCheck -> { diff --git a/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetailsService.java b/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetailsService.java index bd35cf9..8cdc8f0 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetailsService.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetailsService.java @@ -1,12 +1,13 @@ package com.momogo.api.auth.details; -import com.momogo.core.common.util.EmailFormatter; import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.common.util.EmailFormatter; import com.momogo.core.domain.user.dto.response.UserResponse; import com.momogo.core.domain.user.entity.User; import com.momogo.core.domain.user.exception.UserErrorCode; import com.momogo.core.domain.user.mapper.UserMapper; import com.momogo.core.domain.user.repository.UserRepository; +import com.momogo.core.domain.user.service.UserCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.userdetails.UserDetails; @@ -15,7 +16,6 @@ import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; -import java.util.Locale; @Slf4j @RequiredArgsConstructor @@ -24,6 +24,7 @@ public class MoMoGoUserDetailsService implements UserDetailsService { private final UserRepository userRepository; private final UserMapper userMapper; + private final UserCacheService userCacheService; @Override @Transactional(readOnly = true) @@ -32,16 +33,6 @@ public UserDetails loadUserByUsername(String username) { return loadUserDetails(username); } - /** - * JWT 토큰 검증 시 필터에서 호출하는 메서드입니다. - * 유저 정보를 조회합니다. - */ - @Transactional(readOnly = true) - public UserDetails loadUserByUsernameForToken(String username) { - log.debug("[MoMoGoUserDetailsService] loadUserByUsernameForToken 호출됨, email: {}", EmailFormatter.mask(username)); - return loadUserDetails(username); - } - /** * 공통 사용자 정보 조회 및 UserDetails 변환 메서드입니다. * 임시 패스워드가 유효한 경우 로그인 비밀번호를 대체하며, @@ -72,6 +63,22 @@ private UserDetails loadUserDetails(String username) { return new MoMoGoUserDetails(userResponse, passwordForAuth); } + /** + * JWT 토큰 검증 시 필터에서 호출하는 메서드입니다. + * Redis 캐시에서 UserResponse를 조회하여 DB 접근 없이 UserDetails를 즉시 반환합니다. + */ + @Transactional(readOnly = true) + public UserDetails loadUserByUsernameForToken(String username) { + log.debug("[MoMoGoUserDetailsService] loadUserByUsernameForToken 호출됨, email: {}", EmailFormatter.mask(username)); + String normalizedEmail = EmailFormatter.normalize(username); + + // Redis 캐시에서 UserResponse DTO 조회 (캐시 미스 시 DB 조회 후 Redis 자동 저장) + UserResponse userResponse = userCacheService.getUserResponseCached(normalizedEmail); + + // 캐시된 DTO 정보로 UserDetails 즉시 생성하여 리턴 (DB 쿼리 0회) + return new MoMoGoUserDetails(userResponse, ""); + } + /** * 만료된 임시 비밀번호를 DB에서 정리(초기화)합니다. */ diff --git a/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java b/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java index 576aafc..3bb19ec 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java @@ -1,6 +1,7 @@ package com.momogo.api.auth.details; import com.momogo.api.auth.dto.OAuth2Attributes; +import com.momogo.core.common.util.EmailFormatter; import com.momogo.core.domain.user.dto.response.UserResponse; import com.momogo.core.domain.user.entity.User; import com.momogo.core.domain.user.entity.enums.SocialType; @@ -15,8 +16,8 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - import com.momogo.core.common.util.EmailFormatter; +import com.momogo.core.domain.user.service.UserCacheService; import java.util.UUID; @@ -27,6 +28,7 @@ public class OAuth2UserDetailsService extends DefaultOAuth2UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final UserCacheService userCacheService; /** * 소셜(구글/카카오) 인증이 완료된 후 사용자를 조회하거나 신규 가입을 처리합니다. @@ -58,23 +60,17 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } SocialType socialType = SocialType.valueOf(registrationId.toUpperCase()); + String normalizedEmail = EmailFormatter.normalize(attributes.email()); - User user = userRepository.findByEmail(attributes.email()) + User user = userRepository.findByEmail(normalizedEmail) .map(existingUser -> validateSocialUser(existingUser, socialType)) .orElseGet(() -> registerSocialUser(attributes, socialType)); + // Redis 캐시 등록 및 조회 활용 + UserResponse userResponse = userCacheService.getUserResponseCached(normalizedEmail); + return new MoMoGoUserDetails( - UserResponse.builder() - .id(user.getId()) - .email(user.getEmail()) - .name(user.getName()) - .profileImageUrl(user.getProfileImageUrl()) - .role(user.getRole()) - .social(user.getSocial()) - .isBanned(user.getIsBanned()) - .createdAt(user.getCreatedAt()) - .deletedAt(user.getDeletedAt()) - .build(), + userResponse, user.getPassword(), attributes.attributes() ); diff --git a/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java b/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java new file mode 100644 index 0000000..8de9d9e --- /dev/null +++ b/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java @@ -0,0 +1,94 @@ +package com.momogo.api.auth.security; + +import com.momogo.core.common.exception.AuthErrorCode; +import com.momogo.core.common.exception.BusinessException; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * BCrypt 연산(encode/matches)의 동시 실행 개수를 물리 CPU 코어 수 수준으로 제한하는 PasswordEncoder 래퍼입니다. + *

+ * 1. 배경: 가상 스레드 환경에서 CPU 소모가 큰 BCrypt 연산이 동시에 몰리면 OS 컨텍스트 스위칭으로 인해 응답 지연이 치솟는 현상이 발생합니다.
+ * 2. 해결: {@link Semaphore}로 물리 CPU 코어 수만큼만 BCrypt 동시 연산을 허용하고, 대기 시간 초과 요청은 {@link AuthErrorCode#LOGIN_SERVER_BUSY}(HTTP 503)로 빠르게 차단(Fail-Fast)합니다.
+ * 3. 적용: 기존 PasswordEncoder 빈을 대체하여 자동 주입되며, 로직 수정 없이 시스템의 회복성과 응답 지연을 안정적으로 통제합니다. + */ +@Slf4j +@Component +public class BoundedBCryptPasswordEncoder implements PasswordEncoder { + + private final PasswordEncoder delegate; + private final Semaphore semaphore; + private final long acquireTimeoutMs; + private final int maxConcurrency; + + public BoundedBCryptPasswordEncoder( + @Value("${app.security.bcrypt.cost:10}") int bcryptCost, + @Value("${app.security.bcrypt.max-concurrency:#{T(java.lang.Runtime).getRuntime().availableProcessors()}}") int maxConcurrency, + @Value("${app.security.bcrypt.acquire-timeout-ms:3000}") long acquireTimeoutMs, + MeterRegistry meterRegistry + ) { + this.delegate = new BCryptPasswordEncoder(bcryptCost); + this.maxConcurrency = maxConcurrency; + // fair=true: 먼저 대기한 요청이 먼저 슬롯을 얻도록 보장 (FIFO 큐잉, 기아 상태 방지) + this.semaphore = new Semaphore(maxConcurrency, true); + this.acquireTimeoutMs = acquireTimeoutMs; + + meterRegistry.gauge("auth.bcrypt.available.permits", semaphore, Semaphore::availablePermits); + meterRegistry.gauge("auth.bcrypt.queue.length", semaphore, Semaphore::getQueueLength); + + log.info("[BoundedBCryptPasswordEncoder] 초기화 완료 - bcryptCost={}, maxConcurrency={}, acquireTimeoutMs={}ms", + bcryptCost, maxConcurrency, acquireTimeoutMs); + } + + @Override + public String encode(CharSequence rawPassword) { + return runBounded(() -> delegate.encode(rawPassword)); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return runBounded(() -> delegate.matches(rawPassword, encodedPassword)); + } + + @Override + public boolean upgradeEncoding(String encodedPassword) { + return delegate.upgradeEncoding(encodedPassword); + } + + private T runBounded(Supplier operation) { + boolean acquired = false; + try { + acquired = semaphore.tryAcquire(acquireTimeoutMs, TimeUnit.MILLISECONDS); + if (!acquired) { + log.warn("[BoundedBCryptPasswordEncoder] BCrypt 슬롯 획득 타임아웃 발생 - " + + "maxConcurrency={}, 대기 큐 길이={}, 대기시간={}ms 초과", + maxConcurrency, semaphore.getQueueLength(), acquireTimeoutMs); + throw new BusinessException( + AuthErrorCode.LOGIN_SERVER_BUSY, + "로그인 요청이 많아 처리가 지연되고 있습니다. 잠시 후 다시 시도해주세요." + ); + } + return operation.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[BoundedBCryptPasswordEncoder] BCrypt 슬롯 대기 중 인터럽트 발생", e); + throw new BusinessException( + AuthErrorCode.LOGIN_SERVER_BUSY, + "로그인 처리 중 인터럽트가 발생했습니다.", + e + ); + } finally { + if (acquired) { + semaphore.release(); + } + } + } +} \ No newline at end of file diff --git a/momogo-api/src/main/java/com/momogo/api/common/config/CacheConfig.java b/momogo-api/src/main/java/com/momogo/api/common/config/CacheConfig.java new file mode 100644 index 0000000..01032fe --- /dev/null +++ b/momogo-api/src/main/java/com/momogo/api/common/config/CacheConfig.java @@ -0,0 +1,52 @@ +package com.momogo.api.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.momogo.core.domain.user.dto.response.UserResponse; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class CacheConfig { + + private ObjectMapper createBaseObjectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return objectMapper; + } + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = createBaseObjectMapper(); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(30)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(objectMapper))); + + RedisCacheConfiguration userDtosConfig = defaultConfig.serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new Jackson2JsonRedisSerializer<>(objectMapper, UserResponse.class))); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(Map.of("user_dtos", userDtosConfig)) + .build(); + } +} \ No newline at end of file diff --git a/momogo-api/src/main/resources/application.yaml b/momogo-api/src/main/resources/application.yaml index 6e0506d..4626e09 100644 --- a/momogo-api/src/main/resources/application.yaml +++ b/momogo-api/src/main/resources/application.yaml @@ -112,6 +112,11 @@ jwt: same-site: ${JWT_REFRESH_COOKIE_SAME_SITE} app: + security: + bcrypt: + cost: ${APP_SECURITY_BCRYPT_COST:10} + max-concurrency: ${APP_BCRYPT_MAX_CONCURRENCY:2} + acquire-timeout-ms: ${APP_SECURITY_BCRYPT_ACQUIRE_TIMEOUT_MS:3000} async: concurrency-limit: default: ${APP_ASYNC_CONCURRENCY_DEFAULT:50} diff --git a/momogo-core/build.gradle b/momogo-core/build.gradle index 51ef047..687030b 100644 --- a/momogo-core/build.gradle +++ b/momogo-core/build.gradle @@ -10,7 +10,8 @@ dependencies { // DB 영속성 및 유효성 검증, 모니터링 api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-actuator' + api 'org.springframework.boot:spring-boot-starter-actuator' + api 'io.micrometer:micrometer-registry-prometheus' runtimeOnly 'org.postgresql:postgresql' // QueryDSL (Spring Boot 3.x / Jakarta) diff --git a/momogo-core/src/main/java/com/momogo/core/common/exception/AuthErrorCode.java b/momogo-core/src/main/java/com/momogo/core/common/exception/AuthErrorCode.java index 39cdd34..195d292 100644 --- a/momogo-core/src/main/java/com/momogo/core/common/exception/AuthErrorCode.java +++ b/momogo-core/src/main/java/com/momogo/core/common/exception/AuthErrorCode.java @@ -23,7 +23,8 @@ public enum AuthErrorCode implements ErrorCode { SOCIAL_LOGIN_FAILED(6011, "SOCIAL_LOGIN_FAILED", HttpStatus.UNAUTHORIZED, "소셜 로그인 인증에 실패했습니다."), LOCK_ACQUISITION_FAILED(6012, "LOCK_ACQUISITION_FAILED", HttpStatus.SERVICE_UNAVAILABLE, "동시 요청 처리를 위한 락 획득에 실패했습니다. 잠시 후 다시 시도해주세요."), - JWT_SERIALIZATION_FAILED(6013, "JWT_SERIALIZATION_FAILED", HttpStatus.INTERNAL_SERVER_ERROR, "JWT 정보 직렬화에 실패했습니다."); + JWT_SERIALIZATION_FAILED(6013, "JWT_SERIALIZATION_FAILED", HttpStatus.INTERNAL_SERVER_ERROR, "JWT 정보 직렬화에 실패했습니다."), + LOGIN_SERVER_BUSY(6014, "LOGIN_SERVER_BUSY", HttpStatus.SERVICE_UNAVAILABLE, "로그인 요청이 많아 처리가 지연되고 있습니다. 잠시 후 다시 시도해주세요."); private final int numeric; private final String errorKey; diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java new file mode 100644 index 0000000..29ac637 --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java @@ -0,0 +1,45 @@ +package com.momogo.core.domain.user.service; + +import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.common.util.EmailFormatter; +import com.momogo.core.domain.user.dto.response.UserResponse; +import com.momogo.core.domain.user.entity.User; +import com.momogo.core.domain.user.exception.UserErrorCode; +import com.momogo.core.domain.user.mapper.UserMapper; +import com.momogo.core.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 유저 DTO 캐시 조회 기능을 담당하는 전용 서비스 컴포넌트입니다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserCacheService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + /** + * 이메일 기반 UserResponse DTO 캐싱 조회 메서드입니다. + * Redis 캐시에 존재하면 DB 접근 없이 즉시 반환하며, 캐시 미스 시 DB에서 조회하여 Redis에 저장합니다. + * + * @param email 유저 이메일 + * @return UserResponse DTO + */ + @Transactional(readOnly = true) + @Cacheable(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#email)") + public UserResponse getUserResponseCached(String email) { + String normalizedEmail = EmailFormatter.normalize(email); + User user = userRepository.findByEmail(normalizedEmail) + .orElseThrow(() -> { + log.warn("[UserCacheService] 유저를 찾을 수 없습니다, email: {}", EmailFormatter.mask(normalizedEmail)); + return new BusinessException(UserErrorCode.NOT_FOUND); + }); + return userMapper.toResponse(user); + } +} diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java index fed6849..c8d2673 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java @@ -28,14 +28,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import java.io.IOException; import java.io.InputStream; @@ -59,6 +60,7 @@ public class UserServiceImpl implements UserService { private final StorageService storageService; private final UserHardDeleteProcessor hardDeleteProcessor; private final ApplicationEventPublisher eventPublisher; + private final CacheManager cacheManager; @Value("${app.super-admin.email}") private String superAdminEmail; @@ -69,7 +71,6 @@ public class UserServiceImpl implements UserService { * @param request 회원가입 요청 DTO * @return 가입 완료된 회원 정보 DTO */ - // TODO: 회원 가입 시 해당 실제 이메일이 존재하는지 검증 로직 구현 @Override @Transactional public UserResponse createUser(UserCreateRequest request) { @@ -118,6 +119,7 @@ public UserResponse createUser(UserCreateRequest request) { */ @Override @Transactional + @CacheEvict(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#result.email())") public UserResponse updateUser(UUID userId, UserUpdateRequest request, ProfileImageUploadRequest profile) { User user = findActiveUser(userId); @@ -195,6 +197,7 @@ public UserResponse updateUser(UUID userId, UserUpdateRequest request, ProfileIm @Transactional public void softDeleteUser(UUID userId) { User user = findActiveUser(userId); + String normalizedEmail = EmailFormatter.normalize(user.getEmail()); // 탈퇴 전, 소속된 공간이 있다면 퇴장 처리 if (user.getSpace() != null) { @@ -209,6 +212,12 @@ public void softDeleteUser(UUID userId) { // 유저 논리 삭제 user.delete(); + // 메서드 바디 내부에서 안전하게 이메일 기반 캐시 제거 + Cache cache = cacheManager.getCache("user_dtos"); + if (cache != null) { + cache.evict(normalizedEmail); + } + // 탈퇴한 유저 세션 만료 eventPublisher.publishEvent(new UserDeletedEvent(user.getId())); } @@ -242,6 +251,7 @@ public void deleteExpiredUsers() { */ @Override @Transactional + @CacheEvict(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#email)") public void restoreUser(String email, String password) { User user = findUserByEmail(email); @@ -366,6 +376,7 @@ public List findUsersInMySpace(UUID userId) { */ @Override @Transactional + @CacheEvict(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#result.email())") public UserResponse updateUserBannedStatus(UUID userId, boolean banned) { User user = findActiveUser(userId); if (banned) { From 6c34aa32fe0f0f4fcc82264ab477c1bbb3010df6 Mon Sep 17 00:00:00 2001 From: jaejo Date: Sun, 9 Aug 2026 09:59:33 +0900 Subject: [PATCH 2/4] fix: modify CodeRabbit Review --- .../api/auth/details/MoMoGoUserDetails.java | 11 ++++-- .../details/OAuth2UserDetailsService.java | 35 ++++++++++++++----- .../api/auth/handler/LoginFailureHandler.java | 23 +++++++----- .../api/auth/jwt/JwtAuthenticationFilter.java | 22 +++++++----- .../listener/UserSessionEventListener.java | 21 +++++++++++ .../BoundedBCryptPasswordEncoder.java | 18 +++++++--- .../user/event/UserCacheEvictEvent.java | 10 ++++++ .../domain/user/service/UserServiceImpl.java | 25 ++++++------- 8 files changed, 120 insertions(+), 45 deletions(-) create mode 100644 momogo-core/src/main/java/com/momogo/core/domain/user/event/UserCacheEvictEvent.java diff --git a/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java b/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java index 6ab5da0..91beaeb 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/details/MoMoGoUserDetails.java @@ -8,7 +8,6 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; -import java.time.OffsetDateTime; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -50,12 +49,18 @@ public String getName() { @Override public boolean isAccountNonLocked() { - // 계정 정지(밴) 상태인 경우 잠금(Locked) 처리 + // 계정 정지(밴) 상태인 경우에만 잠금(Locked) 처리. return !Boolean.TRUE.equals(userResponse.isBanned()); } + @Override + public boolean isEnabled() { + // 탈퇴(논리 삭제) 진행 중이거나 완료된 계정인 경우 비활성화(Disabled) 처리 + return userResponse.deletedAt() == null; + } + @Override public Map getAttributes() { return attributes; } -} +} \ No newline at end of file diff --git a/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java b/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java index 3bb19ec..5c5606f 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/details/OAuth2UserDetailsService.java @@ -5,9 +5,13 @@ import com.momogo.core.domain.user.dto.response.UserResponse; import com.momogo.core.domain.user.entity.User; import com.momogo.core.domain.user.entity.enums.SocialType; +import com.momogo.core.domain.user.event.UserCacheEvictEvent; +import com.momogo.core.domain.user.mapper.UserMapper; import com.momogo.core.domain.user.repository.UserRepository; +import com.momogo.core.domain.user.service.UserCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -16,8 +20,6 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.momogo.core.common.util.EmailFormatter; -import com.momogo.core.domain.user.service.UserCacheService; import java.util.UUID; @@ -29,6 +31,8 @@ public class OAuth2UserDetailsService extends DefaultOAuth2UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final UserCacheService userCacheService; + private final UserMapper userMapper; + private final ApplicationEventPublisher eventPublisher; /** * 소셜(구글/카카오) 인증이 완료된 후 사용자를 조회하거나 신규 가입을 처리합니다. @@ -62,12 +66,26 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic SocialType socialType = SocialType.valueOf(registrationId.toUpperCase()); String normalizedEmail = EmailFormatter.normalize(attributes.email()); - User user = userRepository.findByEmail(normalizedEmail) - .map(existingUser -> validateSocialUser(existingUser, socialType)) - .orElseGet(() -> registerSocialUser(attributes, socialType)); + // 이번 트랜잭션에서 유저 상태가 변경되었는지(신규 가입 또는 탈퇴 복구) 추적 + boolean[] stateChanged = {false}; - // Redis 캐시 등록 및 조회 활용 - UserResponse userResponse = userCacheService.getUserResponseCached(normalizedEmail); + User user = userRepository.findByEmail(normalizedEmail) + .map(existingUser -> validateSocialUser(existingUser, socialType, stateChanged)) + .orElseGet(() -> { + stateChanged[0] = true; + return registerSocialUser(attributes, socialType); + }); + + UserResponse userResponse; + if (stateChanged[0]) { + // 신규 가입, 복구의 경우 엔티티로부터 직접 변환한다. + userResponse = userMapper.toResponse(user); + // 캐시 갱신은 커밋 확정 이후로 위임 + eventPublisher.publishEvent(new UserCacheEvictEvent(normalizedEmail)); + } else { + // 상태 변경이 없는 일반 로그인: 기존 cache-aside 조회 그대로 사용 (안전함) + userResponse = userCacheService.getUserResponseCached(normalizedEmail); + } return new MoMoGoUserDetails( userResponse, @@ -76,7 +94,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic ); } - private User validateSocialUser(User user, SocialType socialType) { + private User validateSocialUser(User user, SocialType socialType, boolean[] stateChanged) { if (Boolean.TRUE.equals(user.getIsBanned())) { log.error("[OAuth2UserDetailsService] 밴 처리된 유저({}) 로그인 시도", EmailFormatter.mask(user.getEmail())); throw new OAuth2AuthenticationException( @@ -97,6 +115,7 @@ private User validateSocialUser(User user, SocialType socialType) { log.info("[OAuth2UserDetailsService] 탈퇴 대기 중인 소셜 유저({}) 복구 및 로그인 진행", EmailFormatter.mask(user.getEmail())); user.restore(); userRepository.save(user); + stateChanged[0] = true; } if (user.getSocial() != socialType) { diff --git a/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java b/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java index 08e137d..345ed11 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java @@ -1,15 +1,13 @@ package com.momogo.api.auth.handler; import com.fasterxml.jackson.databind.ObjectMapper; -import com.momogo.api.auth.jwt.JwtTokenProvider; import com.momogo.core.common.exception.BusinessException; +import com.momogo.core.domain.user.exception.UserErrorCode; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; @@ -45,12 +43,19 @@ public void onAuthenticationFailure( status = businessException.getErrorCode().getHttpStatus().value(); } else if (exception instanceof LockedException) { - errorMessage = "정지된 계정입니다. 관리자에게 문의하세요."; - errorCode = "USER-BANNED_USER"; - status = HttpServletResponse.SC_FORBIDDEN; - log.warn("로그인 실패(제한된 계정): {}", exception.getClass().getSimpleName()); + // MoMoGoUserDetails.isAccountNonLocked() == false -> 정지(밴) 계정 + errorMessage = UserErrorCode.BANNED_USER.getMessage(); + errorCode = UserErrorCode.BANNED_USER.getCode(); + status = UserErrorCode.BANNED_USER.getHttpStatus().value(); + log.warn("로그인 실패(정지된 계정): {}", exception.getClass().getSimpleName()); + } else if (exception instanceof DisabledException) { - log.warn("로그인 실패(비활성화 계정): {}", exception.getClass().getSimpleName()); + // MoMoGoUserDetails.isEnabled() == false -> 탈퇴(논리 삭제) 계정 + errorMessage = UserErrorCode.ALREADY_IN_PROGRESS_DELETE.getMessage(); + errorCode = UserErrorCode.ALREADY_IN_PROGRESS_DELETE.getCode(); + status = UserErrorCode.ALREADY_IN_PROGRESS_DELETE.getHttpStatus().value(); + log.warn("로그인 실패(탈퇴된 계정): {}", exception.getClass().getSimpleName()); + } else { log.info("로그인 실패: {}", exception.getClass().getSimpleName()); } @@ -67,4 +72,4 @@ public void onAuthenticationFailure( String responseBody = objectMapper.writeValueAsString(errorResponse); response.getWriter().write(responseBody); } -} +} \ No newline at end of file diff --git a/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtAuthenticationFilter.java b/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtAuthenticationFilter.java index 0989a90..a8ee226 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtAuthenticationFilter.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/jwt/JwtAuthenticationFilter.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; + import java.io.IOException; import java.util.Arrays; @@ -31,11 +32,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getRequestURI(); - return path.startsWith("/api/auth/refresh") || - path.startsWith("/swagger-ui") || - path.startsWith("/v3/api-docs") || - path.startsWith("/favicon.svg") || - path.startsWith("/assets/"); + return path.startsWith("/api/auth/refresh") || + path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs") || + path.startsWith("/favicon.svg") || + path.startsWith("/assets/"); } @Override @@ -53,7 +54,9 @@ protected void doFilterInternal( MoMoGoUserDetails userDetails = tokenProvider.parseAccessToken(token); UserDetails currentUserDetails = userDetailsService.loadUserByUsernameForToken(userDetails.getUsername()); - if (currentUserDetails.isAccountNonLocked()) { + // isAccountNonLocked(): 밴(정지) 상태 여부 + // isEnabled(): 탈퇴(논리 삭제) 상태 여부 + if (currentUserDetails.isAccountNonLocked() && currentUserDetails.isEnabled()) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( currentUserDetails, @@ -65,7 +68,10 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication(authentication); } else { jwtRegistry.invalidateJwtInformationByUserId(userDetails.getUserResponse().id()); - log.warn("[JwtFilter] 잠긴 계정 감지: userId= {}, 모든 JWT 세션 무효화", userDetails.getUserResponse().id()); + log.warn("[JwtFilter] 비활성 계정(정지 또는 탈퇴) 감지: userId={}, accountNonLocked={}, enabled={}, 모든 JWT 세션 무효화", + userDetails.getUserResponse().id(), + currentUserDetails.isAccountNonLocked(), + currentUserDetails.isEnabled()); } } else { log.debug("[JwtFilter] 레지스트리에 존재하지 않거나 비활성화된 토큰입니다."); @@ -94,4 +100,4 @@ private String resolveToken(HttpServletRequest request) { } return null; } -} +} \ No newline at end of file diff --git a/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java b/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java index 6c6f7a8..7622317 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java @@ -2,11 +2,15 @@ import com.momogo.api.auth.jwt.JwtRegistry; import com.momogo.core.common.config.AsyncConfig; +import com.momogo.core.common.util.EmailFormatter; import com.momogo.core.domain.user.event.PasswordChangedEvent; import com.momogo.core.domain.user.event.UserBannedEvent; +import com.momogo.core.domain.user.event.UserCacheEvictEvent; import com.momogo.core.domain.user.event.UserDeletedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -19,7 +23,10 @@ @RequiredArgsConstructor public class UserSessionEventListener { + private static final String USER_DTO_CACHE = "user_dtos"; + private final JwtRegistry jwtRegistry; + private final RedisCacheManager cacheManager; // fallbackExecution = true 옵션을 명시하여 트랜잭션이 없을 때도 즉시 이벤트가 정상적으로 처리되도록 안전만 구축 @Async(AsyncConfig.USER_EXECUTOR) @@ -51,4 +58,18 @@ private void invalidateSession(UUID userId, String eventName) { log.error("[UserSessionEventListener] JWT 세션 무효화 실패, event: {} userId: {}", eventName, userId, e); } } + + @Async(AsyncConfig.USER_EXECUTOR) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleUserCacheEvictEvent(UserCacheEvictEvent event) { + String normalizedEmail = EmailFormatter.normalize(event.email()); + log.info("[UserSessionEventListener] 유저 캐시 무효화 이벤트 감지 - email: {}", EmailFormatter.mask(normalizedEmail)); + Cache cache = cacheManager.getCache(USER_DTO_CACHE); + + if (cache != null) { + cache.evict(normalizedEmail); + } else { + log.warn("[UserSessionEventListener] {} 캐시를 찾을 수 없어 evict를 건너뜁니다.", USER_DTO_CACHE); + } + } } diff --git a/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java b/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java index 8de9d9e..ee946e9 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java @@ -5,6 +5,7 @@ import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; @@ -36,6 +37,9 @@ public BoundedBCryptPasswordEncoder( MeterRegistry meterRegistry ) { this.delegate = new BCryptPasswordEncoder(bcryptCost); + if (maxConcurrency < 1) { + throw new IllegalArgumentException("app.security.bcrypt.max-concurrency 설정값은 최소 1 이상이어야 합니다."); + } this.maxConcurrency = maxConcurrency; // fair=true: 먼저 대기한 요청이 먼저 슬롯을 얻도록 보장 (FIFO 큐잉, 기아 상태 방지) this.semaphore = new Semaphore(maxConcurrency, true); @@ -55,7 +59,14 @@ public String encode(CharSequence rawPassword) { @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { - return runBounded(() -> delegate.matches(rawPassword, encodedPassword)); + try { + return runBounded(() -> delegate.matches(rawPassword, encodedPassword)); + } catch (BusinessException e) { + if (e.getErrorCode() == AuthErrorCode.LOGIN_SERVER_BUSY) { + throw new InternalAuthenticationServiceException(e.getMessage(), e); + } + throw e; + } } @Override @@ -71,10 +82,7 @@ private T runBounded(Supplier operation) { log.warn("[BoundedBCryptPasswordEncoder] BCrypt 슬롯 획득 타임아웃 발생 - " + "maxConcurrency={}, 대기 큐 길이={}, 대기시간={}ms 초과", maxConcurrency, semaphore.getQueueLength(), acquireTimeoutMs); - throw new BusinessException( - AuthErrorCode.LOGIN_SERVER_BUSY, - "로그인 요청이 많아 처리가 지연되고 있습니다. 잠시 후 다시 시도해주세요." - ); + throw new BusinessException(AuthErrorCode.LOGIN_SERVER_BUSY); } return operation.get(); } catch (InterruptedException e) { diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/event/UserCacheEvictEvent.java b/momogo-core/src/main/java/com/momogo/core/domain/user/event/UserCacheEvictEvent.java new file mode 100644 index 0000000..e765c73 --- /dev/null +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/event/UserCacheEvictEvent.java @@ -0,0 +1,10 @@ +package com.momogo.core.domain.user.event; + +/** + * 유저의 DB 상태가 변경되어 Redis와 user_dtos 캐시를 무효화할 때 발생하는 이벤트 + * 트랜잭션이 커밋될 경우에만 Redis 캐시가 적용되어야 함 + * + * @param email 정규화된 이메일 - 캐시 키와 동일 + */ +public record UserCacheEvictEvent(String email) { +} diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java index c8d2673..f8ef70f 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserServiceImpl.java @@ -21,6 +21,7 @@ import com.momogo.core.domain.user.entity.enums.UserRole; import com.momogo.core.domain.user.event.PasswordChangedEvent; import com.momogo.core.domain.user.event.UserBannedEvent; +import com.momogo.core.domain.user.event.UserCacheEvictEvent; import com.momogo.core.domain.user.event.UserDeletedEvent; import com.momogo.core.domain.user.exception.UserErrorCode; import com.momogo.core.domain.user.mapper.UserMapper; @@ -28,9 +29,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.Cache; -import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; @@ -60,7 +58,6 @@ public class UserServiceImpl implements UserService { private final StorageService storageService; private final UserHardDeleteProcessor hardDeleteProcessor; private final ApplicationEventPublisher eventPublisher; - private final CacheManager cacheManager; @Value("${app.super-admin.email}") private String superAdminEmail; @@ -119,7 +116,6 @@ public UserResponse createUser(UserCreateRequest request) { */ @Override @Transactional - @CacheEvict(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#result.email())") public UserResponse updateUser(UUID userId, UserUpdateRequest request, ProfileImageUploadRequest profile) { User user = findActiveUser(userId); @@ -185,6 +181,9 @@ public UserResponse updateUser(UUID userId, UserUpdateRequest request, ProfileIm eventPublisher.publishEvent(new PasswordChangedEvent(userId)); } + // DB 저장 로직이 커밋됐을 경우 캐시 갱신 + eventPublisher.publishEvent(new UserCacheEvictEvent(user.getEmail())); + return userMapper.toResponse(user); } @@ -212,14 +211,12 @@ public void softDeleteUser(UUID userId) { // 유저 논리 삭제 user.delete(); - // 메서드 바디 내부에서 안전하게 이메일 기반 캐시 제거 - Cache cache = cacheManager.getCache("user_dtos"); - if (cache != null) { - cache.evict(normalizedEmail); - } + eventPublisher.publishEvent(new UserCacheEvictEvent(normalizedEmail)); // 탈퇴한 유저 세션 만료 eventPublisher.publishEvent(new UserDeletedEvent(user.getId())); + + } /** @@ -251,7 +248,6 @@ public void deleteExpiredUsers() { */ @Override @Transactional - @CacheEvict(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#email)") public void restoreUser(String email, String password) { User user = findUserByEmail(email); @@ -267,6 +263,9 @@ public void restoreUser(String email, String password) { // 복구 처리 (deletedAt = null) user.restore(); + + // 커밋 이후 캐시 evict + eventPublisher.publishEvent(new UserCacheEvictEvent(user.getEmail())); } /** @@ -376,7 +375,6 @@ public List findUsersInMySpace(UUID userId) { */ @Override @Transactional - @CacheEvict(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#result.email())") public UserResponse updateUserBannedStatus(UUID userId, boolean banned) { User user = findActiveUser(userId); if (banned) { @@ -385,6 +383,9 @@ public UserResponse updateUserBannedStatus(UUID userId, boolean banned) { } else { user.unban(); } + + eventPublisher.publishEvent(new UserCacheEvictEvent(user.getEmail())); + return userMapper.toResponse(user); } From e9fe4bb9d7a3327ae74bdd74c0fa56ba8b827b31 Mon Sep 17 00:00:00 2001 From: jaejo Date: Sun, 9 Aug 2026 10:15:57 +0900 Subject: [PATCH 3/4] fix: modify CodeRabbit Review --- .../momogo/api/auth/listener/UserSessionEventListener.java | 1 - .../api/auth/security/BoundedBCryptPasswordEncoder.java | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java b/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java index 7622317..f84489d 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java @@ -59,7 +59,6 @@ private void invalidateSession(UUID userId, String eventName) { } } - @Async(AsyncConfig.USER_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handleUserCacheEvictEvent(UserCacheEvictEvent event) { String normalizedEmail = EmailFormatter.normalize(event.email()); diff --git a/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java b/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java index ee946e9..a657ad5 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/security/BoundedBCryptPasswordEncoder.java @@ -40,6 +40,11 @@ public BoundedBCryptPasswordEncoder( if (maxConcurrency < 1) { throw new IllegalArgumentException("app.security.bcrypt.max-concurrency 설정값은 최소 1 이상이어야 합니다."); } + if (acquireTimeoutMs < 0) { + throw new IllegalArgumentException( + "app.security.bcrypt.acquire-timeout-ms 설정값은 0 이상이어야 합니다." + ); + } this.maxConcurrency = maxConcurrency; // fair=true: 먼저 대기한 요청이 먼저 슬롯을 얻도록 보장 (FIFO 큐잉, 기아 상태 방지) this.semaphore = new Semaphore(maxConcurrency, true); From 2458fd7edb4f5529cbc354e26763889645e3dd63 Mon Sep 17 00:00:00 2001 From: jaejo Date: Mon, 10 Aug 2026 14:51:18 +0900 Subject: [PATCH 4/4] fix: modify CodeRabbit Review 2 --- .../api/auth/handler/LoginFailureHandler.java | 8 ++++++++ .../auth/listener/UserSessionEventListener.java | 17 ++++++++++++----- .../domain/user/service/UserCacheService.java | 7 +++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java b/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java index 345ed11..525bf6f 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/handler/LoginFailureHandler.java @@ -1,6 +1,7 @@ package com.momogo.api.auth.handler; import com.fasterxml.jackson.databind.ObjectMapper; +import com.momogo.core.common.exception.AuthErrorCode; import com.momogo.core.common.exception.BusinessException; import com.momogo.core.domain.user.exception.UserErrorCode; import jakarta.servlet.ServletException; @@ -42,6 +43,13 @@ public void onAuthenticationFailure( errorCode = businessException.getErrorCode().getCode(); status = businessException.getErrorCode().getHttpStatus().value(); + // 서버 과부하(동시성 제한)로 인한 실패는 별도 로그 레벨/메시지로 구분 + if (businessException.getErrorCode() == AuthErrorCode.LOGIN_SERVER_BUSY) { + log.warn("로그인 실패(서버 과부하 동시성 제한): {}", exception.getMessage()); + } else { + log.warn("로그인 실패(비즈니스 예외): {} - {}", errorCode, errorMessage); + } + } else if (exception instanceof LockedException) { // MoMoGoUserDetails.isAccountNonLocked() == false -> 정지(밴) 계정 errorMessage = UserErrorCode.BANNED_USER.getMessage(); diff --git a/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java b/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java index f84489d..eb3dd78 100644 --- a/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java +++ b/momogo-api/src/main/java/com/momogo/api/auth/listener/UserSessionEventListener.java @@ -61,14 +61,21 @@ private void invalidateSession(UUID userId, String eventName) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handleUserCacheEvictEvent(UserCacheEvictEvent event) { + if (event == null || event.email() == null) { + return; + } String normalizedEmail = EmailFormatter.normalize(event.email()); log.info("[UserSessionEventListener] 유저 캐시 무효화 이벤트 감지 - email: {}", EmailFormatter.mask(normalizedEmail)); - Cache cache = cacheManager.getCache(USER_DTO_CACHE); - if (cache != null) { - cache.evict(normalizedEmail); - } else { - log.warn("[UserSessionEventListener] {} 캐시를 찾을 수 없어 evict를 건너뜁니다.", USER_DTO_CACHE); + try { + Cache cache = cacheManager.getCache(USER_DTO_CACHE); + if (cache != null) { + cache.evict(normalizedEmail); + } else { + log.warn("[UserSessionEventListener] {} 캐시를 찾을 수 없어 evict를 건너뜁니다.", USER_DTO_CACHE); + } + } catch (Exception e) { + log.error("[UserSessionEventListener] Redis 캐시 무효화 중 예외 발생 - email: {}", EmailFormatter.mask(normalizedEmail), e); } } } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java index 29ac637..7c2a9f9 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/user/service/UserCacheService.java @@ -32,12 +32,11 @@ public class UserCacheService { * @return UserResponse DTO */ @Transactional(readOnly = true) - @Cacheable(value = "user_dtos", key = "T(com.momogo.core.common.util.EmailFormatter).normalize(#email)") + @Cacheable(value = "user_dtos", key = "#email") public UserResponse getUserResponseCached(String email) { - String normalizedEmail = EmailFormatter.normalize(email); - User user = userRepository.findByEmail(normalizedEmail) + User user = userRepository.findByEmail(email) .orElseThrow(() -> { - log.warn("[UserCacheService] 유저를 찾을 수 없습니다, email: {}", EmailFormatter.mask(normalizedEmail)); + log.warn("[UserCacheService] 유저를 찾을 수 없습니다, email: {}", EmailFormatter.mask(email)); return new BusinessException(UserErrorCode.NOT_FOUND); }); return userMapper.toResponse(user);