diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5a77a76..250d76c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -76,10 +76,10 @@ jobs: echo "DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }}" >> .env # Docker Hub에서 방금 만든 최신 이미지 가져오기 - sudo docker-compose pull + sudo docker compose pull # 컨테이너 재실행 - sudo docker-compose up -d + sudo docker compose up -d # 불필요한 이미지 삭제 sudo docker image prune -f diff --git a/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java b/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java index cbf8e2b..44afd07 100644 --- a/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java +++ b/src/main/java/com/example/scoi/domain/auth/dto/AuthResDTO.java @@ -9,7 +9,7 @@ public class AuthResDTO { // SMS 발송 응답 @JsonInclude(JsonInclude.Include.NON_NULL) public record SmsSendResponse( - LocalDateTime expiredAt, // 인증번호 만료 시간 (발송 시간 + 3분) + LocalDateTime expiredAt, // 인증번호 만료 시간 (발송 시간 + 5분) String verificationCode // 개발/QA 환경에서만 포함 (프로덕션에서는 null) ) {} diff --git a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java index a8286c4..6b62c13 100644 --- a/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java +++ b/src/main/java/com/example/scoi/domain/auth/exception/code/AuthErrorCode.java @@ -25,6 +25,9 @@ public enum AuthErrorCode implements BaseErrorCode { SMS_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH429_1", "잠시 후 다시 시도해주세요. (1분 후 재요청 가능)"), + VERIFICATION_ATTEMPTS_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, + "AUTH429_2", + "인증 시도 횟수를 초과했습니다. 인증번호를 다시 요청해주세요."), // 회원가입/로그인 관련 ALREADY_REGISTERED_PHONE(HttpStatus.CONFLICT, diff --git a/src/main/java/com/example/scoi/domain/auth/service/AuthService.java b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java index fe1f329..c25381c 100644 --- a/src/main/java/com/example/scoi/domain/auth/service/AuthService.java +++ b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java @@ -65,9 +65,11 @@ public class AuthService { private static final String BLACKLIST_PREFIX = "blacklist:"; private static final String SMS_COOLDOWN_PREFIX = "sms:cooldown:"; private static final String SMS_REQUIRED_PREFIX = "sms_required:"; + private static final String SMS_VERIFY_FAIL_PREFIX = "sms:verify:fail:"; // 상수 private static final int SMS_CODE_LENGTH = 6; + private static final int MAX_VERIFY_ATTEMPTS = 5; private static final long SMS_EXPIRATION_MINUTES = 5; private static final long VERIFICATION_EXPIRATION_MINUTES = 10; private static final long REFRESH_TOKEN_SLIDING_DAYS = 14; // 비활성 기준 만료 @@ -85,9 +87,10 @@ public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { // 1. 인증번호 생성 (6자리) String verificationCode = String.format("%06d", ThreadLocalRandom.current().nextInt(1000000)); - // 2. Redis 저장 + // 2. Redis 저장 (새 코드에는 새 시도 예산을 부여) String redisKey = SMS_PREFIX + request.phoneNumber(); redisUtil.set(redisKey, verificationCode, SMS_EXPIRATION_MINUTES, TimeUnit.MINUTES); + redisUtil.delete(SMS_VERIFY_FAIL_PREFIX + request.phoneNumber()); // 3. CoolSMS 발송 if (smsEnabled) { @@ -99,7 +102,7 @@ public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { ); CoolSmsDTO.SendRequest smsRequest = new CoolSmsDTO.SendRequest(message); coolSmsClient.sendMessage(smsRequest); - log.info("SMS 발송 성공: phoneNumber={}, code={}", request.phoneNumber(), verificationCode); + log.info("SMS 발송 성공: phoneNumber={}", request.phoneNumber()); } catch (Exception e) { log.error("SMS 발송 실패: {}", e.getMessage()); throw new AuthException(AuthErrorCode.SMS_SEND_FAILED); @@ -120,19 +123,37 @@ public AuthResDTO.SmsSendResponse sendSms(AuthReqDTO.SmsSendRequest request) { public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest request) { // 1. Redis 조회 String redisKey = SMS_PREFIX + request.phoneNumber(); + String failKey = SMS_VERIFY_FAIL_PREFIX + request.phoneNumber(); String storedCode = redisUtil.get(redisKey); if (storedCode == null) { throw new AuthException(AuthErrorCode.VERIFICATION_CODE_EXPIRED); } - // 2. 검증 + // 2. 검증 (불일치 시 실패 카운터 증가 → 한도 초과 시 코드 무효화) if (!storedCode.equals(request.verificationCode())) { - throw new AuthException(AuthErrorCode.INVALID_VERIFICATION_CODE); + long failCount = redisUtil.increment(failKey, SMS_EXPIRATION_MINUTES, TimeUnit.MINUTES); + + if (failCount >= MAX_VERIFY_ATTEMPTS) { + // 한도 초과: 코드와 카운터 삭제 → 새 코드 재요청 유도 + redisUtil.delete(redisKey); + redisUtil.delete(failKey); + log.warn("SMS 인증 시도 횟수 초과: phoneNumber={}, failCount={}", request.phoneNumber(), failCount); + throw new AuthException(AuthErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED); + } + + long remainingAttempts = MAX_VERIFY_ATTEMPTS - failCount; + log.warn("SMS 인증번호 불일치: phoneNumber={}, failCount={}, remainingAttempts={}", + request.phoneNumber(), failCount, remainingAttempts); + throw new AuthException( + AuthErrorCode.INVALID_VERIFICATION_CODE, + Map.of("remainingAttempts", String.valueOf(remainingAttempts)) + ); } - // 3. SMS 코드 삭제 + // 3. SMS 코드 및 실패 카운터 삭제 (성공 시 초기화) redisUtil.delete(redisKey); + redisUtil.delete(failKey); // 4. Verification Token 발급 String verificationToken = jwtUtil.createVerificationToken(request.phoneNumber()); @@ -151,7 +172,8 @@ public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest reques /** * Verification Token 검증 (범용) * SMS 인증 완료 후 발급된 토큰이 유효한지 검증합니다. - * 다른 도메인(회원가입, 비밀번호 찾기, 휴대폰 번호 변경 등)에서 재사용 가능합니다. + * 토큰은 소멸되지 않으며, TTL(10분) 동안 회원가입·로그인·비밀번호 재설정 등 + * 여러 동작에서 반복 재사용 가능합니다. (매번 SMS 재인증을 요구하지 않기 위한 의도된 설계) * * @param verificationToken SMS 인증 완료 후 발급받은 토큰 * @param phoneNumber 검증할 휴대폰 번호 @@ -175,11 +197,11 @@ public String validateVerificationToken(String verificationToken, String phoneNu } // 간편 비밀번호 재설정 - @jakarta.transaction.Transactional + @Transactional public Void resetPassword( AuthReqDTO.ResetPassword dto ) { - // Verification Token 검증 및 소멸 (SMS 인증 완료 확인) + // Verification Token 검증 (SMS 인증 완료 확인 / 토큰은 TTL 동안 재사용 가능) String phoneNumber = validateVerificationToken(dto.verificationToken(), dto.phoneNumber()); // 사용자 가져오기 @@ -210,6 +232,9 @@ public Void resetPassword( // 로그인 횟수 -> 0 member.resetLoginFailCount(); + + // RT 만료로 걸린 SMS 재인증 플래그 해제 (이미 SMS 인증을 통과했으므로 잠금 완전 해제) + redisUtil.delete(SMS_REQUIRED_PREFIX + phoneNumber); return null; } @@ -460,18 +485,4 @@ public void logout(String phoneNumber, String accessToken) { log.info("로그아웃 성공: phoneNumber={}", phoneNumber); } - - // 임시 - public String generateSmsToken( - String phoneNumber - ) { - // 4. Verification Token 발급 - String verificationToken = jwtUtil.createVerificationToken(phoneNumber); - - // 5. Redis 저장 (10분 TTL) - String tokenKey = VERIFICATION_PREFIX + verificationToken; - redisUtil.set(tokenKey, phoneNumber, VERIFICATION_EXPIRATION_MINUTES, TimeUnit.MINUTES); - - return verificationToken; - } } \ No newline at end of file diff --git a/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java b/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java index 3bebb0b..b2a338a 100644 --- a/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java +++ b/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java @@ -1,13 +1,28 @@ package com.example.scoi.domain.websocket; +import com.example.scoi.domain.websocket.code.WebsocketErrorCode; +import com.example.scoi.domain.websocket.event.WebsocketConnectionEvent.ConnectedEvent; +import com.example.scoi.domain.websocket.event.WebsocketConnectionEvent.DisconnectedEvent; +import com.example.scoi.domain.websocket.exception.WebsocketException; import com.example.scoi.domain.websocket.handler.UpbitTickerHandler; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.socket.client.WebSocketClient; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + @Service @RequiredArgsConstructor @Slf4j @@ -15,13 +30,78 @@ public class WebsocketConnect { private final WebSocketClient webSocketClient; private final UpbitTickerHandler upbitTickerHandler; + private final ObjectProvider selfProvider; // AOP 우회 방지를 위한 프록시 의존성 private static final String PUBLIC_URL = "wss://api.upbit.com/websocket/v1"; - // 실시간 가격 변동 체크 + // 워치독(Half-Open 헬스체크) 전용 스케줄러 + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private ScheduledFuture watchdogTask; + @EventListener(ApplicationReadyEvent.class) - public void connect(){ - log.info("[ Websocket ]: 디페깅 알고리즘 구동 시작..."); - webSocketClient.execute(upbitTickerHandler, PUBLIC_URL); + public void init() { + log.info("[ Websocket ]: 애플리케이션 준비 완료."); + selfProvider.getObject().connectWithRetry(); + } + + @Async + @Retryable( + retryFor = WebsocketException.class, + maxAttempts = 10, + backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 60000) + ) + public void connectWithRetry() { + log.info("[ Websocket ]: 업비트 웹소켓 연결 시도 중... URL: {}", PUBLIC_URL); + try { + // execute()의 리턴값인 Future를 대기(get)하여 타임아웃이나 연결 거부 시 예외를 발생시키도록 유도 + webSocketClient.execute(upbitTickerHandler, PUBLIC_URL).get(5, TimeUnit.SECONDS); + } catch (Exception e) { + log.error("[ Websocket ]: 웹소켓 연결 실패: {}", e.getMessage()); + // 예외를 던져서 @Retryable이 AOP 기반 백오프 및 재시도를 수행하도록 트리거 + throw new WebsocketException(WebsocketErrorCode.UPBIT_WEBSOCKET_ERROR); + } + } + + @Recover + public void recover(Exception e) { + log.error("[ Websocket ]: 웹소켓 재연결 최대 시도 횟수(10회) 초과"); + + // 2시간뒤 다시 시도 + scheduler.schedule(() -> { + log.info("[ Websocket ]: 시스템 복구를 위해 웹소켓 재연결 사이클을 다시 시작합니다."); + selfProvider.getObject().connectWithRetry(); + }, 2, TimeUnit.HOURS); + } + + @EventListener + public void onConnected(ConnectedEvent event) { + log.info("[ Websocket ]: 이벤트 수신 - 웹소켓 연결 활성화 완료."); + + if (watchdogTask != null && !watchdogTask.isCancelled()) { + watchdogTask.cancel(false); + } + // 10초마다 마지막 수신 시간 검사 + watchdogTask = scheduler.scheduleAtFixedRate(() -> { + long idleTime = System.currentTimeMillis() - upbitTickerHandler.getLastMessageTime(); + if (idleTime > 30_000) { // 30초 초과 시 + log.error("[ Websocket ]: 30초간 업비트 응답이 없습니다..."); + upbitTickerHandler.closeSession(); // DisconnectedEvent 유발 + } + }, 10, 10, TimeUnit.SECONDS); + } + + @EventListener + public void onDisconnected(DisconnectedEvent event) { + log.warn("[ Websocket ]: 이벤트 수신 - 웹소켓 연결 해제. 재연결 시도"); + if (watchdogTask != null) { + watchdogTask.cancel(false); + } + selfProvider.getObject().connectWithRetry(); + } + + @PreDestroy + public void shutdown() { + log.info("[ Websocket ]: 워치독 스케줄러 종료 중..."); + scheduler.shutdown(); } } diff --git a/src/main/java/com/example/scoi/domain/websocket/code/WebsocketErrorCode.java b/src/main/java/com/example/scoi/domain/websocket/code/WebsocketErrorCode.java new file mode 100644 index 0000000..acacb6d --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/code/WebsocketErrorCode.java @@ -0,0 +1,20 @@ +package com.example.scoi.domain.websocket.code; + +import com.example.scoi.global.apiPayload.code.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum WebsocketErrorCode implements BaseErrorCode { + + UPBIT_WEBSOCKET_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, + "WEBSOCKET500_1", + "웹소켓 연결 도중 에러가 발생했습니다."), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/scoi/domain/websocket/event/WebsocketConnectionEvent.java b/src/main/java/com/example/scoi/domain/websocket/event/WebsocketConnectionEvent.java new file mode 100644 index 0000000..bca93e0 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/event/WebsocketConnectionEvent.java @@ -0,0 +1,6 @@ +package com.example.scoi.domain.websocket.event; + +public interface WebsocketConnectionEvent { + record ConnectedEvent() implements WebsocketConnectionEvent {} + record DisconnectedEvent() implements WebsocketConnectionEvent {} +} diff --git a/src/main/java/com/example/scoi/domain/websocket/exception/WebsocketException.java b/src/main/java/com/example/scoi/domain/websocket/exception/WebsocketException.java new file mode 100644 index 0000000..19a64d9 --- /dev/null +++ b/src/main/java/com/example/scoi/domain/websocket/exception/WebsocketException.java @@ -0,0 +1,10 @@ +package com.example.scoi.domain.websocket.exception; + +import com.example.scoi.global.apiPayload.code.BaseErrorCode; +import com.example.scoi.global.apiPayload.exception.ScoiException; + +public class WebsocketException extends ScoiException { + public WebsocketException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java b/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java index e9a9e3c..e4e5003 100644 --- a/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java +++ b/src/main/java/com/example/scoi/domain/websocket/handler/UpbitTickerHandler.java @@ -2,14 +2,19 @@ import com.example.scoi.domain.websocket.converter.WebSocketConverter; import com.example.scoi.domain.websocket.dto.UpbitResDTO; +import com.example.scoi.domain.websocket.event.WebsocketConnectionEvent.ConnectedEvent; +import com.example.scoi.domain.websocket.event.WebsocketConnectionEvent.DisconnectedEvent; import com.example.scoi.domain.websocket.service.WebSocketService; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.BinaryWebSocketHandler; @@ -19,18 +24,27 @@ @Component @RequiredArgsConstructor +@Slf4j public class UpbitTickerHandler extends BinaryWebSocketHandler { private final SimpMessageSendingOperations simpMessageSendingOperations; private final WebSocketService webSocketService; + private final ApplicationEventPublisher eventPublisher; private static final ObjectMapper objectMapper = new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); + private volatile long lastMessageTime; + private volatile WebSocketSession currentSession; + @Override public void afterConnectionEstablished( WebSocketSession session ) throws IOException { + this.currentSession = session; + this.lastMessageTime = System.currentTimeMillis(); + log.info("[ Websocket ]: 업비트 웹소켓 연결 성공."); + eventPublisher.publishEvent(new ConnectedEvent()); session.sendMessage(WebSocketConverter.toGetCoinPrice(List.of("KRW-USDT","KRW-USDC"))); } @@ -39,6 +53,8 @@ public void handleBinaryMessage( WebSocketSession session, BinaryMessage message ) throws IOException { + this.lastMessageTime = System.currentTimeMillis(); // Heartbeat 갱신 + String converted = new String(message.getPayload().array(), StandardCharsets.UTF_8); converted = converted.replace("[","").replace("]",""); publish(converted); @@ -50,4 +66,31 @@ public void handleBinaryMessage( protected void publish(String message) { simpMessageSendingOperations.convertAndSend("/topic/ticker", message); } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + log.warn("[ Websocket ]: 업비트 웹소켓 연결이 종료되었습니다. CloseStatus: {}", status); + eventPublisher.publishEvent(new DisconnectedEvent()); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + log.error("[ Websocket ]: 웹소켓 에러 발생: {}", exception.getMessage()); + eventPublisher.publishEvent(new DisconnectedEvent()); + } + + public long getLastMessageTime() { + return lastMessageTime; + } + + public void closeSession() { + if (currentSession != null && currentSession.isOpen()) { + try { + log.warn("[ Websocket ]: 하프 오픈(Half-open) 상태 감지."); + currentSession.close(); + } catch (IOException e) { + log.error("[ Websocket ]: 세션 강제 종료 중 에러 발생: {}", e.getMessage()); + } + } + } } diff --git a/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java index 53bc654..e4e404c 100644 --- a/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java +++ b/src/main/java/com/example/scoi/domain/websocket/service/WebSocketService.java @@ -65,8 +65,6 @@ public void ticker(UpbitResDTO.Ticker dto) { BigDecimal devNumerator = new BigDecimal(Math.abs(dto.tp() - baseline)); BigDecimal dev = devNumerator.divide(BigDecimal.valueOf(Math.max(baseline, EPS)), RoundingMode.HALF_UP); - log.info("coin: {}, dev: {}, baseline: {}, tp: {}", code, dev, baseline, dto.tp()); - // duration 누적/리셋 if (dev.compareTo(BigDecimal.valueOf(DEV_TH)) >= 0){ duration += deltaSec.doubleValue(); diff --git a/src/main/java/com/example/scoi/global/config/RedisConfig.java b/src/main/java/com/example/scoi/global/config/RedisConfig.java index 90d3896..635202a 100644 --- a/src/main/java/com/example/scoi/global/config/RedisConfig.java +++ b/src/main/java/com/example/scoi/global/config/RedisConfig.java @@ -4,6 +4,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -19,4 +21,23 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC redisTemplate.setHashValueSerializer(new StringRedisSerializer()); return redisTemplate; } + + /** + * INCR과 TTL 설정을 한 번의 왕복으로 묶는 스크립트. + * 최초 생성(값이 1)일 때만 만료를 걸어 카운터 수명을 첫 증가 시점에 고정한다. + * ARGV[1]은 밀리초 단위 TTL. + */ + @Bean + public RedisScript incrementWithTtlScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + end + return count + """); + script.setResultType(Long.class); + return script; + } } diff --git a/src/main/java/com/example/scoi/global/config/SecurityConfig.java b/src/main/java/com/example/scoi/global/config/SecurityConfig.java index 9343ee6..e4df3b9 100644 --- a/src/main/java/com/example/scoi/global/config/SecurityConfig.java +++ b/src/main/java/com/example/scoi/global/config/SecurityConfig.java @@ -77,7 +77,7 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( "http://localhost:3000", "http://localhost:8080", - "https://scoi.shop" + "https://scoi.store" )); configuration.setAllowedMethods(Arrays.asList( diff --git a/src/main/java/com/example/scoi/global/config/SwaggerConfig.java b/src/main/java/com/example/scoi/global/config/SwaggerConfig.java index cdc676f..279e745 100644 --- a/src/main/java/com/example/scoi/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/scoi/global/config/SwaggerConfig.java @@ -29,7 +29,7 @@ public OpenAPI swagger() { return new OpenAPI() .info(info) - .addServersItem(new Server().url("https://scoi.shop").description("개발 서버")) + .addServersItem(new Server().url("https://scoi.store").description("개발 서버")) .addServersItem(new Server().url("http://localhost:8080").description("로컬")) .addSecurityItem(securityRequirement) .components(components); diff --git a/src/main/java/com/example/scoi/global/redis/RedisUtil.java b/src/main/java/com/example/scoi/global/redis/RedisUtil.java index 85b46a2..f70fd8f 100644 --- a/src/main/java/com/example/scoi/global/redis/RedisUtil.java +++ b/src/main/java/com/example/scoi/global/redis/RedisUtil.java @@ -3,9 +3,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -19,6 +21,7 @@ public class RedisUtil { private final RedisTemplate redisTemplate; + private final RedisScript incrementWithTtlScript; /** * Redis에 데이터 저장 (TTL 포함) @@ -53,6 +56,24 @@ public String get(String key) { return redisTemplate.opsForValue().get(key); } + /** + * Redis 값을 1 증가시킵니다. + * 증가와 TTL 설정이 한 번의 스크립트로 실행되므로, 중간 실패로 TTL 없는 키가 남지 않습니다. + * 최초 생성(값이 1) 시에만 TTL을 설정하여 카운터 수명을 고정합니다. + * @return 증가 후의 값 + */ + public long increment(String key, long timeout, TimeUnit unit) { + validateInput(key); + + Long count = redisTemplate.execute( + incrementWithTtlScript, + List.of(key), + String.valueOf(unit.toMillis(timeout)) + ); + log.debug("Redis 증가: key={}, count={}", key, count); + return count == null ? 0L : count; + } + /** * Redis에서 데이터 삭제 */ diff --git a/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java b/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..b796cbc --- /dev/null +++ b/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,167 @@ +package com.example.scoi.domain.auth.service; + +import com.example.scoi.domain.auth.dto.AuthReqDTO; +import com.example.scoi.domain.auth.dto.AuthResDTO; +import com.example.scoi.domain.auth.exception.AuthException; +import com.example.scoi.domain.auth.exception.code.AuthErrorCode; +import com.example.scoi.domain.member.repository.MemberRepository; +import com.example.scoi.global.redis.RedisUtil; +import com.example.scoi.global.security.jwt.JwtUtil; +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.concurrent.TimeUnit; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * SMS 인증 무차별 대입 방어 로직 단위 테스트. + * Spring 컨텍스트 없이 Mockito로 협력 객체를 스텁하여 분기별 동작을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private MemberRepository memberRepository; + @Mock + private JwtUtil jwtUtil; + @Mock + private RedisUtil redisUtil; + + @InjectMocks + private AuthService authService; + + private static final String PHONE = "01012345678"; + private static final String CODE = "123456"; + private static final String SMS_KEY = "sms:" + PHONE; + private static final String FAIL_KEY = "sms:verify:fail:" + PHONE; + private static final String COOLDOWN_KEY = "sms:cooldown:" + PHONE; + + private AuthReqDTO.SmsVerifyRequest request(String verificationCode) { + return new AuthReqDTO.SmsVerifyRequest(PHONE, verificationCode); + } + + @Nested + @DisplayName("verifySms - SMS 인증번호 검증") + class VerifySms { + + @Test + @DisplayName("성공: 코드 일치 시 토큰 발급 및 코드·실패카운터 삭제") + void success() { + // given + when(redisUtil.get(SMS_KEY)).thenReturn(CODE); + when(jwtUtil.createVerificationToken(PHONE)).thenReturn("verification-token"); + when(memberRepository.existsByPhoneNumber(PHONE)).thenReturn(true); + + // when + AuthResDTO.SmsVerifyResponse response = authService.verifySms(request(CODE)); + + // then + assertThat(response.verificationToken()).isEqualTo("verification-token"); + assertThat(response.isExistingMember()).isTrue(); + verify(redisUtil).delete(SMS_KEY); // 코드 삭제 + verify(redisUtil).delete(FAIL_KEY); // 실패 카운터 초기화 + } + + @Test + @DisplayName("실패: 저장된 코드가 없으면 VERIFICATION_CODE_EXPIRED, 실패 카운터 증가 안 함") + void expired() { + // given + when(redisUtil.get(SMS_KEY)).thenReturn(null); + + // when & then + assertThatThrownBy(() -> authService.verifySms(request(CODE))) + .isInstanceOf(AuthException.class) + .extracting(e -> ((AuthException) e).getCode()) + .isEqualTo(AuthErrorCode.VERIFICATION_CODE_EXPIRED); + + verify(redisUtil, never()).increment(any(), anyLong(), any()); + } + + @Test + @DisplayName("실패: 코드 불일치 - 한도 미만이면 INVALID_VERIFICATION_CODE + 남은 횟수 반환") + void mismatchUnderLimit() { + // given: 실패 카운터가 2 (한도 5 미만) + when(redisUtil.get(SMS_KEY)).thenReturn(CODE); + when(redisUtil.increment(eq(FAIL_KEY), eq(5L), eq(TimeUnit.MINUTES))).thenReturn(2L); + + // when & then + assertThatThrownBy(() -> authService.verifySms(request("000000"))) + .isInstanceOf(AuthException.class) + .satisfies(e -> { + AuthException ex = (AuthException) e; + assertThat(ex.getCode()).isEqualTo(AuthErrorCode.INVALID_VERIFICATION_CODE); + assertThat(ex.getBind()).containsEntry("remainingAttempts", "3"); // 5 - 2 + }); + + // 한도 미만이므로 코드·카운터 삭제하지 않음 + verify(redisUtil, never()).delete(SMS_KEY); + verify(redisUtil, never()).delete(FAIL_KEY); + } + + @Test + @DisplayName("실패: 코드 불일치 - 한도 도달 시 VERIFICATION_ATTEMPTS_EXCEEDED + 코드·카운터 삭제") + void mismatchLimitExceeded() { + // given: 실패 카운터가 5 (한도 도달) + when(redisUtil.get(SMS_KEY)).thenReturn(CODE); + when(redisUtil.increment(eq(FAIL_KEY), eq(5L), eq(TimeUnit.MINUTES))).thenReturn(5L); + + // when & then + assertThatThrownBy(() -> authService.verifySms(request("000000"))) + .isInstanceOf(AuthException.class) + .extracting(e -> ((AuthException) e).getCode()) + .isEqualTo(AuthErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED); + + // 코드 무효화: 코드·카운터 모두 삭제 + verify(redisUtil).delete(SMS_KEY); + verify(redisUtil).delete(FAIL_KEY); + } + } + + @Nested + @DisplayName("sendSms - SMS 인증번호 발송") + class SendSms { + + @Test + @DisplayName("새 코드 발급 시 이전 실패 카운터를 초기화한다") + void resetsFailCounter() { + // given: 쿨다운 없음 (smsEnabled 기본값 false → 실제 발송 건너뜀) + when(redisUtil.exists(COOLDOWN_KEY)).thenReturn(false); + + // when + authService.sendSms(new AuthReqDTO.SmsSendRequest(PHONE)); + + // then: 새 코드에는 새 시도 예산이 주어져야 한다 + verify(redisUtil).set(eq(SMS_KEY), any(), eq(5L), eq(TimeUnit.MINUTES)); + verify(redisUtil).delete(FAIL_KEY); + } + + @Test + @DisplayName("쿨다운 중이면 SMS_COOLDOWN, 실패 카운터를 건드리지 않는다") + void cooldown() { + // given + when(redisUtil.exists(COOLDOWN_KEY)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> authService.sendSms(new AuthReqDTO.SmsSendRequest(PHONE))) + .isInstanceOf(AuthException.class) + .extracting(e -> ((AuthException) e).getCode()) + .isEqualTo(AuthErrorCode.SMS_COOLDOWN); + + verify(redisUtil, never()).delete(FAIL_KEY); + } + } +} diff --git a/src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java b/src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java new file mode 100644 index 0000000..1d4f13f --- /dev/null +++ b/src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java @@ -0,0 +1,98 @@ +package com.example.scoi.global.redis; + +import com.example.scoi.global.config.RedisConfig; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link RedisUtil#increment} 이 실제 Redis에서 INCR과 TTL 설정을 한 번의 스크립트로 수행하는지 검증한다. + * 단위 테스트는 RedisUtil을 모킹하므로 Lua 스크립트 자체는 여기서만 실증된다. + * Redis가 떠 있지 않으면 테스트 전체를 건너뛴다. + */ +@EnabledIf("redisAvailable") +class RedisUtilIntegrationTest { + + private static final String HOST = "localhost"; + private static final int PORT = 6379; + private static final String KEY = "test:redisutil:increment"; + + private static LettuceConnectionFactory connectionFactory; + private static RedisTemplate redisTemplate; + private static RedisUtil redisUtil; + + static boolean redisAvailable() { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(HOST, PORT), 500); + return true; + } catch (Exception e) { + return false; + } + } + + @BeforeAll + static void setUp() { + connectionFactory = new LettuceConnectionFactory(HOST, PORT); + connectionFactory.afterPropertiesSet(); + + RedisConfig config = new RedisConfig(); + redisTemplate = config.redisTemplate(connectionFactory); + redisTemplate.afterPropertiesSet(); + + redisUtil = new RedisUtil(redisTemplate, config.incrementWithTtlScript()); + } + + @AfterAll + static void tearDown() { + connectionFactory.destroy(); + } + + @AfterEach + void cleanUp() { + redisTemplate.delete(KEY); + } + + @Test + @DisplayName("최초 증가: 1을 반환하고 TTL이 설정된다") + void firstIncrementSetsTtl() { + long count = redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + + assertThat(count).isEqualTo(1L); + assertThat(redisTemplate.getExpire(KEY, TimeUnit.SECONDS)) + .isPositive() + .isLessThanOrEqualTo(300L); + } + + @Test + @DisplayName("재증가: 값은 늘어나지만 TTL은 초기화되지 않는다 (고정 창)") + void subsequentIncrementDoesNotResetTtl() { + redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + // 첫 증가가 건 TTL을 짧게 덮어써 두면, 재증가가 TTL을 다시 5분으로 늘리는지 확인할 수 있다 + redisTemplate.expire(KEY, 30, TimeUnit.SECONDS); + + long count = redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + + assertThat(count).isEqualTo(2L); + assertThat(redisTemplate.getExpire(KEY, TimeUnit.SECONDS)).isLessThanOrEqualTo(30L); + } + + @Test + @DisplayName("저장된 값은 정수 문자열이라 get()으로 읽을 수 있다") + void valueIsReadableAsString() { + redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + redisUtil.increment(KEY, 5, TimeUnit.MINUTES); + + assertThat(redisUtil.get(KEY)).isEqualTo("2"); + } +}