From 4041c381b35c792f5e2c49a326ad2d7deba86e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Fri, 3 Jul 2026 10:34:34 +0900 Subject: [PATCH 01/11] =?UTF-8?q?:bug:=20fix:=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=82=B4=20docker=20com?= =?UTF-8?q?pose=20=ED=95=98=EC=9D=B4=ED=94=88=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 4d6be6fd7fcd9ff98e227c14ed3d8f920896fd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EA=B7=9C?= Date: Sat, 4 Jul 2026 10:58:59 +0900 Subject: [PATCH 02/11] =?UTF-8?q?:recycle:=20refactor:=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B3=80=EA=B2=BD=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?(scoi.shop=20->=20scoi.store)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/scoi/global/config/SecurityConfig.java | 2 +- src/main/java/com/example/scoi/global/config/SwaggerConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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); From c7f29993d2517624b07f89c28fa5412c82ae1ed0 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 9 Jul 2026 02:45:23 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20feat:=20RedisUtil=EC=97=90=20?= =?UTF-8?q?=EC=9B=90=EC=9E=90=EC=A0=81=20increment=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/scoi/global/redis/RedisUtil.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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..d251144 100644 --- a/src/main/java/com/example/scoi/global/redis/RedisUtil.java +++ b/src/main/java/com/example/scoi/global/redis/RedisUtil.java @@ -53,6 +53,22 @@ public String get(String key) { return redisTemplate.opsForValue().get(key); } + /** + * Redis 값을 원자적으로 1 증가시킵니다. + * 최초 생성(값이 1) 시에만 TTL을 설정하여 카운터 수명을 고정합니다. + * @return 증가 후의 값 + */ + public long increment(String key, long timeout, TimeUnit unit) { + validateInput(key); + + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, timeout, unit); + } + log.debug("Redis 증가: key={}, count={}", key, count); + return count == null ? 0L : count; + } + /** * Redis에서 데이터 삭제 */ From 723cb7edf9b070ca2af944031c7531c0c9fa15dc Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 9 Jul 2026 02:45:38 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20feat:=20SMS=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=8B=9C=EB=8F=84=20=ED=9A=9F=EC=88=98=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/auth/exception/code/AuthErrorCode.java | 3 +++ 1 file changed, 3 insertions(+) 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, From 98efdee19b482ee691d26631e779682ffe4c664c Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 9 Jul 2026 02:45:48 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20SMS=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=AC=B4=EC=B0=A8=EB=B3=84=20=EB=8C=80?= =?UTF-8?q?=EC=9E=85=20=EB=B0=A9=EC=96=B4=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EB=85=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scoi/domain/auth/service/AuthService.java | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) 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..f7eaea1 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; // 비활성 기준 만료 @@ -99,7 +101,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 +122,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 +171,8 @@ public AuthResDTO.SmsVerifyResponse verifySms(AuthReqDTO.SmsVerifyRequest reques /** * Verification Token 검증 (범용) * SMS 인증 완료 후 발급된 토큰이 유효한지 검증합니다. - * 다른 도메인(회원가입, 비밀번호 찾기, 휴대폰 번호 변경 등)에서 재사용 가능합니다. + * 토큰은 소멸되지 않으며, TTL(10분) 동안 회원가입·로그인·비밀번호 재설정 등 + * 여러 동작에서 반복 재사용 가능합니다. (매번 SMS 재인증을 요구하지 않기 위한 의도된 설계) * * @param verificationToken SMS 인증 완료 후 발급받은 토큰 * @param phoneNumber 검증할 휴대폰 번호 @@ -179,7 +200,7 @@ public String validateVerificationToken(String verificationToken, String phoneNu public Void resetPassword( AuthReqDTO.ResetPassword dto ) { - // Verification Token 검증 및 소멸 (SMS 인증 완료 확인) + // Verification Token 검증 (SMS 인증 완료 확인 / 토큰은 TTL 동안 재사용 가능) String phoneNumber = validateVerificationToken(dto.verificationToken(), dto.phoneNumber()); // 사용자 가져오기 From 75696737d12c3fbfcded8738e8a053decfd6a3c5 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 9 Jul 2026 02:46:10 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=E2=9C=A8=20feat:=20verifySms=20=EB=AC=B4?= =?UTF-8?q?=EC=B0=A8=EB=B3=84=20=EB=8C=80=EC=9E=85=20=EB=B0=A9=EC=96=B4=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthServiceTest.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java 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..2cda873 --- /dev/null +++ b/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,132 @@ +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; + +/** + * {@link AuthService#verifySms} 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 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); + } + } +} From a63b6d8a0594abb7a598b62ecfdcf60fddd9237b Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 9 Jul 2026 20:20:20 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20INCR/TTL?= =?UTF-8?q?=EC=9D=84=20Lua=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=EB=A1=9C?= =?UTF-8?q?=20=EC=9B=90=EC=9E=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INCR과 EXPIRE가 별도 왕복이라 중간 실패 시 TTL 없는 카운터가 남을 수 있었다. 두 명령을 단일 스크립트로 묶어 한 번의 왕복으로 실행한다. MULTI/EXEC는 INCR 반환값을 읽어 EXPIRE 여부를 분기할 수 없어 사용하지 않았다. Co-Authored-By: Claude Opus 4.8 --- .../scoi/global/config/RedisConfig.java | 21 ++++ .../example/scoi/global/redis/RedisUtil.java | 15 ++- .../redis/RedisUtilIntegrationTest.java | 98 +++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/example/scoi/global/redis/RedisUtilIntegrationTest.java 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/redis/RedisUtil.java b/src/main/java/com/example/scoi/global/redis/RedisUtil.java index d251144..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 포함) @@ -54,17 +57,19 @@ public String get(String key) { } /** - * Redis 값을 원자적으로 1 증가시킵니다. + * Redis 값을 1 증가시킵니다. + * 증가와 TTL 설정이 한 번의 스크립트로 실행되므로, 중간 실패로 TTL 없는 키가 남지 않습니다. * 최초 생성(값이 1) 시에만 TTL을 설정하여 카운터 수명을 고정합니다. * @return 증가 후의 값 */ public long increment(String key, long timeout, TimeUnit unit) { validateInput(key); - Long count = redisTemplate.opsForValue().increment(key); - if (count != null && count == 1L) { - redisTemplate.expire(key, timeout, unit); - } + 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; } 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"); + } +} From 8c99c8266c4b49a83700c77790272d52b7773c17 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Thu, 9 Jul 2026 20:20:27 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=EC=83=88=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=8B=A4=ED=8C=A8=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendSms가 failKey를 지우지 않아 실패 카운터가 코드 수명을 넘어 이월됐다. 카운터 TTL은 첫 실패 기준, 코드 TTL은 발급 기준이라 두 창이 정렬되지 않아 4회 실패 후 새 코드를 받으면 오답 한 번에 즉시 무효화됐다. 새 코드에 새 시도 예산을 부여한다. 쿨다운(1분)이 재발급 남용을 막는다. Co-Authored-By: Claude Opus 4.8 --- .../scoi/domain/auth/service/AuthService.java | 3 +- .../domain/auth/service/AuthServiceTest.java | 37 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) 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 f7eaea1..2043ca0 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 @@ -87,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) { 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 index 2cda873..b796cbc 100644 --- a/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/example/scoi/domain/auth/service/AuthServiceTest.java @@ -28,7 +28,7 @@ import static org.mockito.Mockito.when; /** - * {@link AuthService#verifySms} SMS 무차별 대입 방어 로직 단위 테스트. + * SMS 인증 무차별 대입 방어 로직 단위 테스트. * Spring 컨텍스트 없이 Mockito로 협력 객체를 스텁하여 분기별 동작을 검증한다. */ @ExtendWith(MockitoExtension.class) @@ -48,6 +48,7 @@ class AuthServiceTest { 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); @@ -129,4 +130,38 @@ void mismatchLimitExceeded() { 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); + } + } } From d052a5fb15d7099005ad4e0e1d85ce7ff612bfde Mon Sep 17 00:00:00 2001 From: JuHeon Date: Fri, 10 Jul 2026 17:14:53 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=99=BB=EF=B8=8F=20refactor:=20LSTM=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/websocket/WebsocketConnect.java | 82 ++++++++++++++++++- .../websocket/code/WebsocketErrorCode.java | 20 +++++ .../event/WebsocketConnectionEvent.java | 6 ++ .../exception/WebsocketException.java | 10 +++ .../websocket/handler/UpbitTickerHandler.java | 43 ++++++++++ .../websocket/service/WebSocketService.java | 2 - 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/example/scoi/domain/websocket/code/WebsocketErrorCode.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/event/WebsocketConnectionEvent.java create mode 100644 src/main/java/com/example/scoi/domain/websocket/exception/WebsocketException.java 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..0deb1f3 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,72 @@ 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회)를 초과했습니다."); + } + + @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..c8dbf3f 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 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(); From 5880e288860f1be60c6518c3272dfd0c16c5706e Mon Sep 17 00:00:00 2001 From: JuHeon Date: Fri, 10 Jul 2026 17:31:07 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=EA=B0=80=EC=8B=9C=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20&=202?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/scoi/domain/websocket/WebsocketConnect.java | 8 +++++++- .../scoi/domain/websocket/handler/UpbitTickerHandler.java | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) 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 0deb1f3..b2a338a 100644 --- a/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java +++ b/src/main/java/com/example/scoi/domain/websocket/WebsocketConnect.java @@ -64,7 +64,13 @@ public void connectWithRetry() { @Recover public void recover(Exception e) { - log.error("[ Websocket ]: 웹소켓 재연결이 최대 시도 횟수(10회)를 초과했습니다."); + log.error("[ Websocket ]: 웹소켓 재연결 최대 시도 횟수(10회) 초과"); + + // 2시간뒤 다시 시도 + scheduler.schedule(() -> { + log.info("[ Websocket ]: 시스템 복구를 위해 웹소켓 재연결 사이클을 다시 시작합니다."); + selfProvider.getObject().connectWithRetry(); + }, 2, TimeUnit.HOURS); } @EventListener 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 c8dbf3f..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 @@ -35,7 +35,7 @@ public class UpbitTickerHandler extends BinaryWebSocketHandler { .setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); private volatile long lastMessageTime; - private WebSocketSession currentSession; + private volatile WebSocketSession currentSession; @Override public void afterConnectionEstablished( From 670853a71263a49643b825dbc63ac0f8d1ddeb75 Mon Sep 17 00:00:00 2001 From: Myungjun Jang Date: Sat, 11 Jul 2026 17:29:55 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Auth=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=94=94=ED=85=8C=EC=9D=BC/?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=EC=84=B1=20=EB=B3=B4=EC=99=84=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SmsSendResponse.expiredAt 주석 오류 정정 (3분 -> 5분) - 데드코드 generateSmsToken 제거 (호출처 없음, 인증 우회 소지) - resetPassword 트랜잭션 애노테이션 스프링으로 통일 - resetPassword에서 sms_required 플래그 삭제로 잠금 해제 일관성 확보 Co-Authored-By: Claude Opus 4.8 --- .../scoi/domain/auth/dto/AuthResDTO.java | 2 +- .../scoi/domain/auth/service/AuthService.java | 19 ++++--------------- 2 files changed, 5 insertions(+), 16 deletions(-) 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/service/AuthService.java b/src/main/java/com/example/scoi/domain/auth/service/AuthService.java index 2043ca0..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 @@ -197,7 +197,7 @@ public String validateVerificationToken(String verificationToken, String phoneNu } // 간편 비밀번호 재설정 - @jakarta.transaction.Transactional + @Transactional public Void resetPassword( AuthReqDTO.ResetPassword dto ) { @@ -232,6 +232,9 @@ public Void resetPassword( // 로그인 횟수 -> 0 member.resetLoginFailCount(); + + // RT 만료로 걸린 SMS 재인증 플래그 해제 (이미 SMS 인증을 통과했으므로 잠금 완전 해제) + redisUtil.delete(SMS_REQUIRED_PREFIX + phoneNumber); return null; } @@ -482,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