From 66467c40fde3ccce712c24ef6290921dced288c2 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Thu, 6 Aug 2026 21:38:59 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=ED=9C=B4=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98,=20=EA=B3=84=EC=A0=95=20=ED=95=98=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 11 ++++ .../InsightonAuthApplication.java | 4 ++ .../insightonauth/client/CoreClient.java | 13 +++++ .../controller/CoreController.java | 27 ++++++++++ .../dto/core/AuthUserResponse.java | 11 ++++ .../dto/core/ManagerGroupExistsResponse.java | 6 +++ .../CoreServiceUnavailableException.java | 7 +++ .../ManagerGroupExistsException.java | 7 +++ .../insightonauth/redis/RedisKey.java | 4 +- .../insightonauth/redis/RedisService.java | 5 ++ .../repository/UserRepository.java | 6 +++ .../scheduler/UserHardDeleteScheduler.java | 49 ++++++++++++++++++ .../UserSleepConversionScheduler.java | 50 +++++++++++++++++++ .../insightonauth/service/UserService.java | 4 ++ .../service/impl/UserServiceImpl.java | 33 ++++++++++++ src/main/resources/application-dev.properties | 11 +++- 16 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java diff --git a/pom.xml b/pom.xml index 4c418f3..0160dbf 100644 --- a/pom.xml +++ b/pom.xml @@ -126,6 +126,17 @@ spring-boot-starter-mail + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + io.micrometer diff --git a/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java b/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java index 8820c31..e9bc098 100644 --- a/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java +++ b/src/main/java/com/nhnacademy/insightonauth/InsightonAuthApplication.java @@ -3,9 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableDiscoveryClient +@EnableFeignClients +@EnableScheduling public class InsightonAuthApplication { public static void main(String[] args) { diff --git a/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java new file mode 100644 index 0000000..7f45130 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java @@ -0,0 +1,13 @@ +package com.nhnacademy.insightonauth.client; + +import com.nhnacademy.insightonauth.dto.core.ManagerGroupExistsResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient(name = "insighton-core", url = "lb://insighton-core") +public interface CoreClient { + + @GetMapping("/internal/v1/users/{userId}/manager-groups/exists") + ManagerGroupExistsResponse existsManagerGroup(@PathVariable Long userId); +} diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java new file mode 100644 index 0000000..c89bcb7 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java @@ -0,0 +1,27 @@ +package com.nhnacademy.insightonauth.controller; + +import com.nhnacademy.insightonauth.dto.core.AuthUserResponse; +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/users") +@RequiredArgsConstructor +public class CoreController { + + private final UserService userService; + + @GetMapping("{userId}") + public ResponseEntity getUserById(@PathVariable("user-id") Long userId) { + User user = userService.findById(userId); + + return ResponseEntity.ok( + new AuthUserResponse(user.getUserId(), user.getUserName(), user.getPhoneNumber(), user.getStatus())); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java new file mode 100644 index 0000000..2760afb --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/dto/core/AuthUserResponse.java @@ -0,0 +1,11 @@ +package com.nhnacademy.insightonauth.dto.core; + +import com.nhnacademy.insightonauth.entity.Status; + +public record AuthUserResponse( + Long userId, + String userName, + String userPhoneNumber, + Status userStatus +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java new file mode 100644 index 0000000..66715b1 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/dto/core/ManagerGroupExistsResponse.java @@ -0,0 +1,6 @@ +package com.nhnacademy.insightonauth.dto.core; + +public record ManagerGroupExistsResponse( + boolean exists +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java new file mode 100644 index 0000000..1a53e30 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class CoreServiceUnavailableException extends RuntimeException { + public CoreServiceUnavailableException(String message) { + super(message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java new file mode 100644 index 0000000..b696db5 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class ManagerGroupExistsException extends RuntimeException { + public ManagerGroupExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java index c43c880..e086c8d 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java @@ -10,7 +10,9 @@ public enum RedisKey { LOGIN_LOCK("login-lock:"), PASSWORD_RESET("password-reset:"), REACTIVE("reactive:"), - BLACKLIST("blacklist:"); + BLACKLIST("blacklist:"), + HARD_DELETE_SCHEDULER_LOCK("scheduler-lock:hard-delete-users"), + SLEEP_CONVERSION_SCHEDULER_LOCK("scheduler-lock:sleep-conversion");; private final String prefix; diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java index daff191..e89cca5 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java @@ -27,4 +27,9 @@ public void delete(String key) { public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } + + public boolean setIfAbsent(String key, String value, Duration ttl) { + Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, ttl); + return Boolean.TRUE.equals(result); + } } diff --git a/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java b/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java index 86ce5ee..616dead 100644 --- a/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java +++ b/src/main/java/com/nhnacademy/insightonauth/repository/UserRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.OffsetDateTime; +import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { @@ -25,4 +27,8 @@ Page findByEmailContainingAndUserNameContaining( boolean existsByEmail(String email); boolean existsByPhoneNumber(String phoneNumber); + + List findByStatusAndWithdrawnAtBefore(Status status, OffsetDateTime withdrawnAtBefore); + + List findByStatusAndLastLoginAtBefore(Status status, OffsetDateTime dateTime);; } diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java new file mode 100644 index 0000000..96161d1 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java @@ -0,0 +1,49 @@ +package com.nhnacademy.insightonauth.scheduler; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.redis.RedisKey; +import com.nhnacademy.insightonauth.redis.RedisService; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserHardDeleteScheduler { + + private final UserService userService; + private final RedisService redisService; + + @Scheduled(cron = "0 0 1 * * *") + public void hardDeleteExpiredUsers() { + String lockKey = RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix(); + boolean acquired = redisService.setIfAbsent(lockKey, "locked", Duration.ofMinutes(30)); + + if (!acquired) { + log.info("다른 인스턴스가 이미 이 작업을 실행 중입니다. 건너뜁니다."); + return; + } + + try { + List targets = userService.findExpiredWithdrawnUsers(); + + for (User user : targets) { + try { + userService.deleteUser(user.getUserId()); + } catch (Exception e) { + log.warn("탈퇴 계정 삭제 실패 - userId: {}, error: {}", user.getUserId(), e.getMessage()); + } + } + + log.info("탈퇴 계정 물리 삭제 완료 - 대상 {}건", targets.size()); + } finally { + redisService.delete(lockKey); + } + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java new file mode 100644 index 0000000..5787175 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java @@ -0,0 +1,50 @@ +package com.nhnacademy.insightonauth.scheduler; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.redis.RedisKey; +import com.nhnacademy.insightonauth.redis.RedisService; +import com.nhnacademy.insightonauth.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserSleepConversionScheduler { + + private final UserService userService; + private final RedisService redisService; + + @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 4시 + public void convertInactiveUsersToSleep() { + String lockKey = RedisKey.SLEEP_CONVERSION_SCHEDULER_LOCK.getPrefix(); + boolean acquired = redisService.setIfAbsent(lockKey, "locked", Duration.ofMinutes(30)); + + if (!acquired) { + log.info("다른 인스턴스가 이미 휴면 전환 작업을 실행 중입니다. 건너뜁니다."); + return; + } + + try { + List targets = userService.findInactiveUsers(); + + for (User user : targets) { + try { + userService.sleep(user.getUserId()); + } catch (Exception e) { + // 하나가 실패해도 나머지는 진행되게 + log.warn("휴면 전환 실패 - userId: {}, error: {}", user.getUserId(), e.getMessage()); + } + } + + log.info("휴면 전환 완료 - 대상 {}건", targets.size()); + } finally { + redisService.delete(lockKey); + } + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java index c010b69..1fe67bf 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java @@ -62,4 +62,8 @@ public interface UserService { UserLoginResponse oauthLogin(String provider, String code); TokenRefreshResponse refresh(Long userId, String refreshToken); + + List findExpiredWithdrawnUsers(); + + List findInactiveUsers(); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index 903b8d4..ab3c659 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -1,9 +1,11 @@ package com.nhnacademy.insightonauth.service.impl; +import com.nhnacademy.insightonauth.client.CoreClient; import com.nhnacademy.insightonauth.client.OauthClient; import com.nhnacademy.insightonauth.dto.auth.TokenRefreshResponse; import com.nhnacademy.insightonauth.dto.auth.UserLoginResponse; import com.nhnacademy.insightonauth.dto.auth.UserSignupResponse; +import com.nhnacademy.insightonauth.dto.core.ManagerGroupExistsResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; import com.nhnacademy.insightonauth.email.EmailService; import com.nhnacademy.insightonauth.entity.*; @@ -16,6 +18,7 @@ import com.nhnacademy.insightonauth.util.PhoneNumberUtil; import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,6 +30,7 @@ import java.util.Optional; import java.util.UUID; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -41,6 +45,7 @@ public class UserServiceImpl implements UserService { private final EmailService emailService; private final OauthClient oauthClient; private final OauthService oauthService; + private final CoreClient coreClient; @Override public UserSignupResponse createUser(String email, String password, String userName, String phoneNumber, Role role, String verificationToken) { @@ -262,7 +267,21 @@ public void withdraw(Long userId) { throw new InvalidUserStatusException("이미 탈퇴한 계정입니다."); } + ManagerGroupExistsResponse response; + try { + response = coreClient.existsManagerGroup(userId); + } catch (Exception e) { + log.warn("Core 서비스 호출 실패로 탈퇴를 차단합니다 - userId: {}, 원인: {}", userId, e.getMessage()); + throw new CoreServiceUnavailableException( + "일시적으로 그룹 정보를 확인할 수 없어 탈퇴가 제한됩니다. 잠시 후 다시 시도해주세요."); + } + + if (response.exists()) { + throw new ManagerGroupExistsException("그룹 관리자 역할이 있어 탈퇴할 수 없습니다."); + } + user.withdraw(); + redisService.delete(RedisKey.REFRESH.getPrefix() + userId); } @Override @@ -357,6 +376,20 @@ public TokenRefreshResponse refresh(Long userId, String refreshToken) { return new TokenRefreshResponse(accessToken); } + @Override + @Transactional(readOnly = true) + public List findExpiredWithdrawnUsers() { + return userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + } + + @Override + @Transactional(readOnly = true) + public List findInactiveUsers() { + return userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + } + private User findActiveUser(Long userId) { User user = findById(userId); if (user.getStatus() != Status.ACTIVE) { diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 7163775..68202a5 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -78,4 +78,13 @@ mail.from-name=InsightOn # =============================== oauth.google.client-id=${GOOGLE_CLIENT_ID} oauth.google.client-secret=${GOOGLE_CLIENT_SECRET} -oauth.google.redirect-uri=https://insighton.store/oauth/callback \ No newline at end of file +oauth.google.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# Resilience4j +# =============================== +spring.cloud.openfeign.circuitbreaker.enabled=true + +resilience4j.circuitbreaker.instances.insighton-core.sliding-window-size=10 +resilience4j.circuitbreaker.instances.insighton-core.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.insighton-core.wait-duration-in-open-state=10s From 4dfa5070ec0449b84c2ae8f715519d2a727dc6d3 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 15:17:24 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20github=20oauth=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 --- http/user.http | 8 ++ .../insightonauth/client/OauthClient.java | 61 +----------- .../client/OauthClientResolver.java | 22 +++++ .../client/impl/GithubOauthClient.java | 96 +++++++++++++++++++ .../client/impl/GoogleOauthClient.java | 68 +++++++++++++ .../insightonauth/email/EmailService.java | 1 - .../exception/EmailNotFoundException.java | 7 ++ .../UnsupportedOAuthProviderException.java | 7 ++ .../handler/GlobalExceptionHandler.java | 10 ++ .../service/impl/MyPageServiceImpl.java | 2 +- .../service/impl/UserServiceImpl.java | 12 ++- src/main/resources/application-dev.properties | 10 +- src/test/resources/application.properties | 2 +- 13 files changed, 240 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java diff --git a/http/user.http b/http/user.http index dca1c5f..70d773d 100644 --- a/http/user.http +++ b/http/user.http @@ -18,6 +18,14 @@ Content-Type: application/json "email": "test@example.com" } +### 1-2. 이메일 인증코드 발송 +POST http://localhost:8080/api/v1/auth/email/verify-request +Content-Type: application/json + +{ + "email": "whakr56@naver.com" +} + ### 1-3. 이메일 인증코드 확인 POST http://localhost:8080/api/v1/auth/email/verify-confirm Content-Type: application/json diff --git a/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java index 123875f..7ae7851 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/OauthClient.java @@ -1,65 +1,8 @@ package com.nhnacademy.insightonauth.client; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; -import java.util.Map; +public interface OauthClient { -@Component -@RequiredArgsConstructor -public class OauthClient { - - @Value("${oauth.google.client-id}") - private String clientId; - - @Value("${oauth.google.client-secret}") - private String clientSecret; - - @Value("${oauth.google.redirect-uri}") - private String redirectUri; - - private final RestClient restClient = RestClient.create(); - - public OauthUserInfo getUserInfo(String provider, String code) { - String accessToken = requestAccessToken(code); - return requestUserInfo(accessToken); - } - - private String requestAccessToken(String code) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("code", code); - body.add("client_id", clientId); - body.add("client_secret", clientSecret); - body.add("redirect_uri", redirectUri); - body.add("grant_type", "authorization_code"); - - Map response = restClient.post() - .uri("https://oauth2.googleapis.com/token") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(body) - .retrieve() - .body(Map.class); - - return (String) response.get("access_token"); - } - - private OauthUserInfo requestUserInfo(String accessToken) { - Map userInfo = restClient.get() - .uri("https://www.googleapis.com/oauth2/v3/userinfo") - .header("Authorization", "Bearer " + accessToken) - .retrieve() - .body(Map.class); - - return new OauthUserInfo( - (String) userInfo.get("email"), - (String) userInfo.get("name"), - (String) userInfo.get("sub") - ); - } + OauthUserInfo getUserInfo(String code); } diff --git a/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java b/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java new file mode 100644 index 0000000..8e880bc --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/OauthClientResolver.java @@ -0,0 +1,22 @@ +package com.nhnacademy.insightonauth.client; + +import com.nhnacademy.insightonauth.exception.UnsupportedOAuthProviderException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class OauthClientResolver { + // 스프링이 bean 객체를 가져다줌 + private final Map clients; + + public OauthClient resolve(String provider) { + OauthClient client = clients.get(provider + "OauthClient"); + if (client == null) { + throw new UnsupportedOAuthProviderException(provider); + } + return client; + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java new file mode 100644 index 0000000..3261be2 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java @@ -0,0 +1,96 @@ +package com.nhnacademy.insightonauth.client.impl; + +import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import com.nhnacademy.insightonauth.exception.EmailNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +@Component("githubOauthClient") +@RequiredArgsConstructor +public class GithubOauthClient implements OauthClient { + + @Value("${oauth.github.client-id}") + private String clientId; + + @Value("${oauth.github.client-secret}") + private String clientSecret; + + @Value("${oauth.redirect-uri}") + private String redirectUri; + + private final RestClient restClient = RestClient.create(); + + @Override + public OauthUserInfo getUserInfo(String code) { + String accessToken = requestAccessToken(code); + return requestUserInfo(accessToken); + } + + private String requestAccessToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + + Map response = restClient.post() + .uri("https://github.com/login/oauth/access_token") // GitHub URL로 수정 + .header("Accept", "application/json") // JSON 응답 요청 (필수) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(Map.class); + + return (String) response.get("access_token"); + } + + private OauthUserInfo requestUserInfo(String accessToken) { + Map userInfo = restClient.get() + .uri("https://api.github.com/user") // GitHub URL로 수정 + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + String email = (String) userInfo.get("email"); + if (email == null) { + email = requestPrimaryEmail(accessToken); // 비공개 이메일 대응 + } + + String name = (String) userInfo.get("name"); + if (name == null || name.isBlank()) { + name = (String) userInfo.get("login"); // name이 없으면 login(아이디)으로 대체 + } + + Object id = userInfo.get("id"); // GitHub은 sub가 아니라 id (숫자) + + return new OauthUserInfo( + email, + name, + String.valueOf(id) + ); + } + + // 이메일 열람 불가의 경우 api를 통해서 가져와야함 + private String requestPrimaryEmail(String accessToken) { + List> emails = restClient.get() + .uri("https://api.github.com/user/emails") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(List.class); + + return emails.stream() + .filter(e -> Boolean.TRUE.equals(e.get("primary"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElseThrow(() -> new EmailNotFoundException("GitHub 계정에서 이메일을 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java new file mode 100644 index 0000000..8ff4eb8 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java @@ -0,0 +1,68 @@ +package com.nhnacademy.insightonauth.client.impl; + +import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +@Component("googleOauthClient") +@RequiredArgsConstructor +public class GoogleOauthClient implements OauthClient { + + @Value("${oauth.google.client-id}") + private String clientId; + + @Value("${oauth.google.client-secret}") + private String clientSecret; + + @Value("${oauth.redirect-uri}") + private String redirectUri; + + private final RestClient restClient = RestClient.create(); + + @Override + public OauthUserInfo getUserInfo(String code) { + String accessToken = requestAccessToken(code); + return requestUserInfo(accessToken); + } + + private String requestAccessToken(String code) { +// FormHttpMessageConverter + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + body.add("grant_type", "authorization_code"); + + Map response = restClient.post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(Map.class); + + return (String) response.get("access_token"); + } + + private OauthUserInfo requestUserInfo(String accessToken) { + Map userInfo = restClient.get() + .uri("https://www.googleapis.com/oauth2/v3/userinfo") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + return new OauthUserInfo( + (String) userInfo.get("email"), + (String) userInfo.get("name"), + (String) userInfo.get("sub") + ); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java index 9f25f91..23247c3 100644 --- a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java +++ b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java @@ -75,7 +75,6 @@ private void send(String to, String subject, String text) { javaMailSender.send(message); } catch (MessagingException | UnsupportedEncodingException e) { throw new EmailSendException("이메일 발송에 실패했습니다.", e); - } catch (MailException e) { log.error("이메일 전송 실패(타임아웃 등) - to: {}, error: {}", to, e.getMessage()); throw new EmailSendException("이메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.", e); diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java new file mode 100644 index 0000000..9eb93bc --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class EmailNotFoundException extends RuntimeException { + public EmailNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java new file mode 100644 index 0000000..b02541c --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class UnsupportedOAuthProviderException extends RuntimeException { + public UnsupportedOAuthProviderException(String message) { + super(message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..0e618d0 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java @@ -0,0 +1,10 @@ +package com.nhnacademy.insightonauth.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java index 9d6031f..fc08509 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java @@ -73,7 +73,7 @@ public List findMyOauths(Long userId) { public void linkOauth(Long userId, String provider, String code) { User user = userService.findById(userId); // 이미 로그인된 그 사람 - OauthUserInfo userInfo = oauthClient.getUserInfo(provider, code); // Google 검증 + OauthUserInfo userInfo = oauthClient.getUserInfo(code); // Google 검증 // 이 소셜 계정이 이미 다른 사람 것인지 확인 (중요!) oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()) diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index ab3c659..ed0d630 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -2,6 +2,7 @@ import com.nhnacademy.insightonauth.client.CoreClient; import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.client.OauthClientResolver; import com.nhnacademy.insightonauth.dto.auth.TokenRefreshResponse; import com.nhnacademy.insightonauth.dto.auth.UserLoginResponse; import com.nhnacademy.insightonauth.dto.auth.UserSignupResponse; @@ -36,6 +37,8 @@ @RequiredArgsConstructor public class UserServiceImpl implements UserService { + // Transactional 전파 필요없으면 빼기 어노테이션 붙이기 + // private 메소드의 Transactional의 붙는 경우 proxy가 적용안 될수 있음 private final UserRepository userRepository; private final UserCredentialService userCredentialService; private final UserRoleService userRoleService; @@ -43,7 +46,7 @@ public class UserServiceImpl implements UserService { private final JwtProvider jwtProvider; private final RedisService redisService; private final EmailService emailService; - private final OauthClient oauthClient; + private final OauthClientResolver oauthClientResolver; private final OauthService oauthService; private final CoreClient coreClient; @@ -104,6 +107,7 @@ public UserLoginResponse login(String email, String password) { } // 유저 계정 존재 여부 숨기기 + // 기존대로 UserNotFound로 하는건 어떤가 User user = userRepository.findByEmail(email) .orElseThrow(() -> new InvalidCredentialsException("유저를 찾을 수 없습니다.")); UserCredential credential = userCredentialService.findByUser(user); @@ -218,6 +222,7 @@ public void updatePhoneNumber(Long userId, String phoneNumber) { user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); } + // 전화번호 찾기시 인증이 방법시 생각해보기 @Override public String findMaskedEmail(String userName, String phoneNumber) { String normalized = PhoneNumberUtil.normalize(phoneNumber); @@ -259,6 +264,7 @@ public void activate(Long userId) { user.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); } + //탈톼시 비밀번호 확인 @Override public void withdraw(Long userId) { User user = findById(userId); @@ -325,7 +331,8 @@ public void deleteUser(Long userId) { @Override public UserLoginResponse oauthLogin(String provider, String code) { - OauthUserInfo userInfo = oauthClient.getUserInfo(provider, code); + OauthClient oauthClient = oauthClientResolver.resolve(provider); + OauthUserInfo userInfo = oauthClient.getUserInfo(code); Optional existingOauth = oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()); @@ -356,6 +363,7 @@ public UserLoginResponse oauthLogin(String provider, String code) { @Override public TokenRefreshResponse refresh(Long userId, String refreshToken) { + // base64 왜쓰는지 try { jwtProvider.validateRefreshToken(userId, refreshToken); // Redis의 jti와 대조 검증 } catch (JwtException e) { diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 68202a5..a133361 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -74,11 +74,17 @@ mail.from-address=insighton@insighton.store mail.from-name=InsightOn # =============================== -# OAuth +# OAuth - Google # =============================== oauth.google.client-id=${GOOGLE_CLIENT_ID} oauth.google.client-secret=${GOOGLE_CLIENT_SECRET} -oauth.google.redirect-uri=https://insighton.store/oauth/callback +oauth.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# OAuth - GitHub +# =============================== +oauth.github.client-id=${GITHUB_CLIENT_ID} +oauth.github.client-secret=${GITHUB_CLIENT_SECRET} # =============================== # Resilience4j diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 7631639..52130b8 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -72,7 +72,7 @@ mail.from-name=InsightOn # =============================== oauth.google.client-id=GOOGLE_CLIENT_ID_TEST oauth.google.client-secret=GOOGLE_CLIENT_SECRET_TEST -oauth.google.redirect-uri=https://insighton.store/oauth/callback +oauth.redirect-uri=https://insighton.store/oauth/callback # test의 경우 config가 필요 없음 spring.cloud.config.enabled=false \ No newline at end of file From 1becaf8e942b7cfc96b30dd521e0a7c5cae3d2e8 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 15:19:04 +0900 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20github=20oauth=EB=A1=9C=20MyPageSe?= =?UTF-8?q?rviceImpl=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../insightonauth/service/impl/MyPageServiceImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java index fc08509..4d23aa0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java @@ -1,6 +1,7 @@ package com.nhnacademy.insightonauth.service.impl; import com.nhnacademy.insightonauth.client.OauthClient; +import com.nhnacademy.insightonauth.client.OauthClientResolver; import com.nhnacademy.insightonauth.dto.mypage.MyInfoResponse; import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthResponse; @@ -28,7 +29,7 @@ public class MyPageServiceImpl implements MyPageService { private final UserCredentialService userCredentialService; private final UserRoleService userRoleService; private final OauthService oauthService; - private final OauthClient oauthClient; + private final OauthClientResolver oauthClientResolver; private final PasswordEncoder passwordEncoder; @Override @@ -73,6 +74,7 @@ public List findMyOauths(Long userId) { public void linkOauth(Long userId, String provider, String code) { User user = userService.findById(userId); // 이미 로그인된 그 사람 + OauthClient oauthClient = oauthClientResolver.resolve(provider); OauthUserInfo userInfo = oauthClient.getUserInfo(code); // Google 검증 // 이 소셜 계정이 이미 다른 사람 것인지 확인 (중요!) From 92e5d3510d70caa38606d9561f60d65ca945cdca Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 16:11:47 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20Service=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20?= =?UTF-8?q?readOnly=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/MyPageServiceImpl.java | 3 +++ .../service/impl/OauthServiceImpl.java | 3 +++ .../impl/UserCredentialServiceImpl.java | 2 ++ .../service/impl/UserRoleServiceImpl.java | 1 + src/test/resources/application.properties | 21 +++++++++++++++++-- 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java index 4d23aa0..ac5ea1a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java @@ -33,6 +33,7 @@ public class MyPageServiceImpl implements MyPageService { private final PasswordEncoder passwordEncoder; @Override + @Transactional(readOnly = true) public MyInfoResponse findMyInfo(Long userId) { User user = userService.findById(userId); @@ -53,6 +54,7 @@ public void updatePassword(Long userId, String currentPassword, String newPasswo } @Override + @Transactional(readOnly = true) public List findMyRoles(Long userId) { User user = userService.findById(userId); @@ -62,6 +64,7 @@ public List findMyRoles(Long userId) { } @Override + @Transactional(readOnly = true) public List findMyOauths(Long userId) { User user = userService.findById(userId); diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java index 0fc9acc..0626830 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/OauthServiceImpl.java @@ -55,6 +55,7 @@ public void deleteAllByUser(User user) { } @Override + @Transactional(readOnly = true) public Oauth findOauth(User user, String provider) { return oauthRepository.findByUserAndProvider(user, provider) .orElseThrow(() -> new OauthNotFoundException("연동된 소셜 계정을 찾을 수 없습니다.")); @@ -62,11 +63,13 @@ public Oauth findOauth(User user, String provider) { // 전체 삭제 @Override + @Transactional(readOnly = true) public List findAllByUser(User user) { return oauthRepository.findByUser(user); } @Override + @Transactional(readOnly = true) public Optional findByProviderAndProviderUserId(String provider, String providerUserId) { return oauthRepository.findByProviderAndProviderUserId(provider, providerUserId); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java index 978e0e0..0dc8306 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserCredentialServiceImpl.java @@ -28,6 +28,7 @@ public void create(User user, String password) { } @Override + @Transactional(readOnly = true) public UserCredential findByUser(User user) { UserCredential userCredential = userCredentialRepository.findByUser(user) .orElseThrow(() -> new UserCredentialsNotFoundException("유저 인증 정보가 없습니다.")); @@ -49,6 +50,7 @@ public void updatePassword(OffsetDateTime now, User user, String password) { } @Override + @Transactional(readOnly = true) public boolean exists(User user) { return userCredentialRepository.existsByUser(user); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java index 822a041..b9d4855 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserRoleServiceImpl.java @@ -46,6 +46,7 @@ public void removeRole(User user, Role role) { } @Override + @Transactional(readOnly = true) public List findByUser(User user) { List userRoleList = userRoleRepository.findByUser(user); diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 52130b8..ed595a8 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -72,7 +72,24 @@ mail.from-name=InsightOn # =============================== oauth.google.client-id=GOOGLE_CLIENT_ID_TEST oauth.google.client-secret=GOOGLE_CLIENT_SECRET_TEST -oauth.redirect-uri=https://insighton.store/oauth/callback # test의 경우 config가 필요 없음 -spring.cloud.config.enabled=false \ No newline at end of file +spring.cloud.config.enabled=false + +oauth.redirect-uri=https://insighton.store/oauth/callback + +# =============================== +# OAuth - GitHub +# =============================== +oauth.github.client-id=GITHUB_CLIENT_ID_TEST +oauth.github.client-secret=GITHUB_CLIENT_SECRET_TEST + + +# =============================== +# Resilience4j +# =============================== +spring.cloud.openfeign.circuitbreaker.enabled=true + +resilience4j.circuitbreaker.instances.insighton-core.sliding-window-size=10 +resilience4j.circuitbreaker.instances.insighton-core.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.insighton-core.wait-duration-in-open-state=10s From eb3d3e4ff9cde8b3a3b459382ef603fdca017dd3 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 17:02:26 +0900 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20FeignClient=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/user.http | 8 -------- .../nhnacademy/insightonauth/client/CoreClient.java | 2 +- .../insightonauth/client/impl/GoogleOauthClient.java | 10 +++++++++- .../insightonauth/controller/CoreController.java | 2 +- .../nhnacademy/insightonauth/redis/RedisService.java | 1 + src/main/resources/application-dev.properties | 1 + 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/http/user.http b/http/user.http index 70d773d..dca1c5f 100644 --- a/http/user.http +++ b/http/user.http @@ -18,14 +18,6 @@ Content-Type: application/json "email": "test@example.com" } -### 1-2. 이메일 인증코드 발송 -POST http://localhost:8080/api/v1/auth/email/verify-request -Content-Type: application/json - -{ - "email": "whakr56@naver.com" -} - ### 1-3. 이메일 인증코드 확인 POST http://localhost:8080/api/v1/auth/email/verify-confirm Content-Type: application/json diff --git a/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java index 7f45130..b0d05ef 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/CoreClient.java @@ -5,7 +5,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -@FeignClient(name = "insighton-core", url = "lb://insighton-core") +@FeignClient(name = "insighton-core") public interface CoreClient { @GetMapping("/internal/v1/users/{userId}/manager-groups/exists") diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java index 8ff4eb8..ac5e2c0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GoogleOauthClient.java @@ -4,12 +4,15 @@ import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; +import java.time.Duration; import java.util.Map; @Component("googleOauthClient") @@ -25,7 +28,12 @@ public class GoogleOauthClient implements OauthClient { @Value("${oauth.redirect-uri}") private String redirectUri; - private final RestClient restClient = RestClient.create(); + private final RestClient restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(Duration.ofSeconds(3)) + .withReadTimeout(Duration.ofSeconds(5)))) + .build(); @Override public OauthUserInfo getUserInfo(String code) { diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java index c89bcb7..3138435 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/CoreController.java @@ -18,7 +18,7 @@ public class CoreController { private final UserService userService; @GetMapping("{userId}") - public ResponseEntity getUserById(@PathVariable("user-id") Long userId) { + public ResponseEntity getUserById(@PathVariable("userId") Long userId) { User user = userService.findById(userId); return ResponseEntity.ok( diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java index e89cca5..91b8001 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java @@ -28,6 +28,7 @@ public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } + // 수정 해야함 public boolean setIfAbsent(String key, String value, Duration ttl) { Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, ttl); return Boolean.TRUE.equals(result); diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index a133361..e46b8c7 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -91,6 +91,7 @@ oauth.github.client-secret=${GITHUB_CLIENT_SECRET} # =============================== spring.cloud.openfeign.circuitbreaker.enabled=true +# 오픈페인이랑 확인 필요 resilience4j.circuitbreaker.instances.insighton-core.sliding-window-size=10 resilience4j.circuitbreaker.instances.insighton-core.failure-rate-threshold=50 resilience4j.circuitbreaker.instances.insighton-core.wait-duration-in-open-state=10s From 41c57a449f47086a7bc2e270b0c9c7e7adadf34c Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 22:12:24 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20BusinessException=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A0=84=EC=97=AD=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 +++ .../exception/BusinessException.java | 17 ++++++++ .../CoreServiceUnavailableException.java | 4 +- .../exception/DuplicateEmailException.java | 4 +- .../DuplicatePhoneNumberException.java | 4 +- .../exception/EmailNotFoundException.java | 4 +- .../exception/EmailSendException.java | 6 ++- .../insightonauth/exception/ErrorCode.java | 40 +++++++++++++++++++ .../exception/ErrorResponse.java | 7 ++++ .../InvalidCredentialsException.java | 4 +- .../InvalidReactiveTokenException.java | 4 +- .../InvalidRefreshTokenException.java | 4 +- .../exception/InvalidUserException.java | 4 +- .../exception/InvalidUserStatusException.java | 4 +- .../InvalidVerificationCodeException.java | 4 +- .../InvalidVerificationTokenException.java | 4 +- .../exception/LastLoginMethodException.java | 4 +- .../LoginTemporarilyLockedException.java | 4 +- .../ManagerGroupExistsException.java | 4 +- .../OauthAlreadyLinkedException.java | 4 +- .../exception/OauthNotFoundException.java | 4 +- .../RefreshTokenNotFoundException.java | 4 +- .../RestorePeriodExpiredException.java | 4 +- .../UnsupportedOAuthProviderException.java | 4 +- .../UserCredentialsNotFoundException.java | 4 +- .../exception/UserNotFoundException.java | 4 +- .../exception/UserRoleNotFoundException.java | 4 +- ...erificationTemporarilyLockedException.java | 4 +- .../handler/GlobalExceptionHandler.java | 34 ++++++++++++++++ 29 files changed, 153 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java diff --git a/pom.xml b/pom.xml index 0160dbf..3eb9445 100644 --- a/pom.xml +++ b/pom.xml @@ -132,6 +132,11 @@ spring-cloud-starter-openfeign + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + org.springframework.cloud spring-cloud-starter-circuitbreaker-resilience4j diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java b/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java new file mode 100644 index 0000000..1a704af --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/BusinessException.java @@ -0,0 +1,17 @@ +package com.nhnacademy.insightonauth.exception; + +import org.springframework.http.HttpStatus; + +public class BusinessException extends RuntimeException { + private final HttpStatus status; + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.status = errorCode.getStatus(); + } + + public HttpStatus getStatus() { return status; } + public ErrorCode getErrorCode() { return errorCode; } +} \ No newline at end of file diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java index 1a53e30..c5b05d1 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/CoreServiceUnavailableException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class CoreServiceUnavailableException extends RuntimeException { +public class CoreServiceUnavailableException extends BusinessException { public CoreServiceUnavailableException(String message) { - super(message); + super(ErrorCode.CORE_SERVICE_UNAVAILABLE, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java index 4e2d5e8..e741874 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicateEmailException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class DuplicateEmailException extends RuntimeException { +public class DuplicateEmailException extends BusinessException { public DuplicateEmailException(String message) { - super(message); + super(ErrorCode.DUPLICATE_EMAIL, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java index 04cac4e..98868c6 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/DuplicatePhoneNumberException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class DuplicatePhoneNumberException extends RuntimeException { +public class DuplicatePhoneNumberException extends BusinessException { public DuplicatePhoneNumberException(String message) { - super(message); + super(ErrorCode.DUPLICATE_PHONE_NUMBER, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java index 9eb93bc..6973962 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class EmailNotFoundException extends RuntimeException { +public class EmailNotFoundException extends BusinessException { public EmailNotFoundException(String message) { - super(message); + super(ErrorCode.EMAIL_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java index 3de9e2f..10a7f7e 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailSendException.java @@ -1,7 +1,9 @@ package com.nhnacademy.insightonauth.exception; -public class EmailSendException extends RuntimeException { +public class EmailSendException extends BusinessException { public EmailSendException(String message, Exception e) { - super(message); + super(ErrorCode.EMAIL_SEND_FAILED, message); + // 왜 이메일이 실패했는지 정확인 exception이 로그이 보이기 위한 코드 + initCause(e); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java new file mode 100644 index 0000000..4284e7e --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java @@ -0,0 +1,40 @@ +package com.nhnacademy.insightonauth.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + CORE_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE), + DUPLICATE_EMAIL(HttpStatus.CONFLICT), + DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT), + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND), + EMAIL_SEND_FAILED(HttpStatus.SERVICE_UNAVAILABLE), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED), + INVALID_REACTIVE_TOKEN(HttpStatus.BAD_REQUEST), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED), + INVALID_USER(HttpStatus.FORBIDDEN), + INVALID_USER_STATUS(HttpStatus.CONFLICT), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST), + INVALID_VERIFICATION_TOKEN(HttpStatus.BAD_REQUEST), + LAST_LOGIN_METHOD(HttpStatus.CONFLICT), + LOGIN_TEMPORARILY_LOCKED(HttpStatus.LOCKED), + MANAGER_GROUP_EXISTS(HttpStatus.CONFLICT), + OAUTH_ALREADY_LINKED(HttpStatus.CONFLICT), + OAUTH_NOT_FOUND(HttpStatus.NOT_FOUND), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND), + RESTORE_PERIOD_EXPIRED(HttpStatus.GONE), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST), + USER_CREDENTIALS_NOT_FOUND(HttpStatus.NOT_FOUND), + USER_NOT_FOUND(HttpStatus.NOT_FOUND), + USER_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND), + VERIFICATION_TEMPORARILY_LOCKED(HttpStatus.LOCKED); + + private final HttpStatus status; + + ErrorCode(HttpStatus status) { + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java new file mode 100644 index 0000000..bca0216 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public record ErrorResponse( + int status, + String message +) { +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java index 1af1f55..98c00e8 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidCredentialsException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidCredentialsException extends RuntimeException { +public class InvalidCredentialsException extends BusinessException { public InvalidCredentialsException(String message) { - super(message); + super(ErrorCode.INVALID_CREDENTIALS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java index 074a82b..f168d04 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidReactiveTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidReactiveTokenException extends RuntimeException { +public class InvalidReactiveTokenException extends BusinessException { public InvalidReactiveTokenException(String message) { - super(message); + super(ErrorCode.INVALID_REACTIVE_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java index bf1e007..73a606a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidRefreshTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidRefreshTokenException extends RuntimeException { +public class InvalidRefreshTokenException extends BusinessException { public InvalidRefreshTokenException(String message) { - super(message); + super(ErrorCode.INVALID_REFRESH_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java index 3262d98..fc50ed7 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidUserException extends RuntimeException { +public class InvalidUserException extends BusinessException { public InvalidUserException(String message) { - super(message); + super(ErrorCode.INVALID_USER, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java index d4a8bd5..be026ad 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidUserStatusException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidUserStatusException extends RuntimeException { +public class InvalidUserStatusException extends BusinessException { public InvalidUserStatusException(String message) { - super(message); + super(ErrorCode.INVALID_USER_STATUS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java index e06567f..91e10f0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationCodeException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidVerificationCodeException extends RuntimeException { +public class InvalidVerificationCodeException extends BusinessException { public InvalidVerificationCodeException(String message) { - super(message); + super(ErrorCode.INVALID_VERIFICATION_CODE, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java index 2b58f9c..ac51b62 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidVerificationTokenException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class InvalidVerificationTokenException extends RuntimeException { +public class InvalidVerificationTokenException extends BusinessException { public InvalidVerificationTokenException(String message) { - super(message); + super(ErrorCode.INVALID_VERIFICATION_TOKEN, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java b/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java index e4c71e8..a7537fa 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/LastLoginMethodException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class LastLoginMethodException extends RuntimeException { +public class LastLoginMethodException extends BusinessException { public LastLoginMethodException(String message) { - super(message); + super(ErrorCode.LAST_LOGIN_METHOD, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java index 60d6100..c8dd2ce 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/LoginTemporarilyLockedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class LoginTemporarilyLockedException extends RuntimeException { +public class LoginTemporarilyLockedException extends BusinessException { public LoginTemporarilyLockedException(String message) { - super(message); + super(ErrorCode.LOGIN_TEMPORARILY_LOCKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java index b696db5..e5ad93a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ManagerGroupExistsException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class ManagerGroupExistsException extends RuntimeException { +public class ManagerGroupExistsException extends BusinessException { public ManagerGroupExistsException(String message) { - super(message); + super(ErrorCode.MANAGER_GROUP_EXISTS, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java index 3bf4f8f..8350e51 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthAlreadyLinkedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class OauthAlreadyLinkedException extends RuntimeException { +public class OauthAlreadyLinkedException extends BusinessException { public OauthAlreadyLinkedException(String message) { - super(message); + super(ErrorCode.OAUTH_ALREADY_LINKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java index ceba420..02c3ad2 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class OauthNotFoundException extends RuntimeException { +public class OauthNotFoundException extends BusinessException { public OauthNotFoundException(String message) { - super(message); + super(ErrorCode.OAUTH_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java index 7dfd0c8..5df5727 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/RefreshTokenNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class RefreshTokenNotFoundException extends RuntimeException { +public class RefreshTokenNotFoundException extends BusinessException { public RefreshTokenNotFoundException(String message) { - super(message); + super(ErrorCode.REFRESH_TOKEN_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java b/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java index 75684d2..9b18984 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/RestorePeriodExpiredException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class RestorePeriodExpiredException extends RuntimeException { +public class RestorePeriodExpiredException extends BusinessException { public RestorePeriodExpiredException(String message) { - super(message); + super(ErrorCode.RESTORE_PERIOD_EXPIRED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java index b02541c..b5ff99c 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UnsupportedOAuthProviderException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UnsupportedOAuthProviderException extends RuntimeException { +public class UnsupportedOAuthProviderException extends BusinessException { public UnsupportedOAuthProviderException(String message) { - super(message); + super(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER, "지원하지 않는 OAuth 제공자입니다: " + message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java index 6b4d2f7..0d0cef9 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserCredentialsNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserCredentialsNotFoundException extends RuntimeException { +public class UserCredentialsNotFoundException extends BusinessException { public UserCredentialsNotFoundException(String message) { - super(message); + super(ErrorCode.USER_CREDENTIALS_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java index 7b2266a..74473c4 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserNotFoundException extends RuntimeException { +public class UserNotFoundException extends BusinessException { public UserNotFoundException(String message) { - super(message); + super(ErrorCode.USER_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java b/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java index 9e8856c..0256109 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/UserRoleNotFoundException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class UserRoleNotFoundException extends RuntimeException { +public class UserRoleNotFoundException extends BusinessException { public UserRoleNotFoundException(String message) { - super(message); + super(ErrorCode.USER_ROLE_NOT_FOUND, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java index d989fc3..6712f8c 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationTemporarilyLockedException.java @@ -1,7 +1,7 @@ package com.nhnacademy.insightonauth.exception; -public class VerificationTemporarilyLockedException extends RuntimeException { +public class VerificationTemporarilyLockedException extends BusinessException { public VerificationTemporarilyLockedException(String message) { - super(message); + super(ErrorCode.VERIFICATION_TEMPORARILY_LOCKED, message); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java index 0e618d0..a4c24cc 100644 --- a/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/nhnacademy/insightonauth/handler/GlobalExceptionHandler.java @@ -1,10 +1,44 @@ package com.nhnacademy.insightonauth.handler; +import com.nhnacademy.insightonauth.exception.BusinessException; +import com.nhnacademy.insightonauth.exception.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + + // 비즈니스 로직 실패 + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e) { + return ResponseEntity.status(e.getStatus()) + .body(new ErrorResponse(e.getStatus().value(), e.getMessage())); + } + + // Validate 실패 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse("잘못된 요청입니다."); + return ResponseEntity.badRequest() + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), message)); + } + + // 그 외 + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception e) { + log.error("예상치 못한 예외 발생", e); + return ResponseEntity.internalServerError() + .body(new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "서버 오류가 발생했습니다.")); + } } From a52c55b00ebfe77c355a8af7dbe1e9269a3ab0aa Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 22:13:03 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20ApiResponse?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0,=20DTO=20=EC=A7=81=EC=A0=91=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminController.java | 17 +++-- .../controller/MypageController.java | 20 +++--- .../controller/UserController.java | 68 +++++++++---------- .../insightonauth/dto/ApiResponse.java | 23 ------- .../insightonauth/dto/ErrorResponse.java | 19 ------ 5 files changed, 50 insertions(+), 97 deletions(-) delete mode 100644 src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java delete mode 100644 src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java b/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java index b31fc66..e744613 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/AdminController.java @@ -1,7 +1,6 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.ApiResponse; import com.nhnacademy.insightonauth.dto.admin.AdminFindUsersResponse; import com.nhnacademy.insightonauth.dto.admin.AdminUserDetailResponse; import com.nhnacademy.insightonauth.dto.admin.RoleChangeRequest; @@ -24,41 +23,41 @@ public class AdminController { // 회원 목록 조회 (검색·페이징) @GetMapping("/users") - public ResponseEntity>> findUsers( + public ResponseEntity> findUsers( @RequestParam(required = false) String email, @RequestParam(required = false) String userName, @RequestParam(required = false) Status status, Pageable pageable) { Page response = adminUserService.findUsers(email, userName, status, pageable); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 회원 상세 조회 @GetMapping("/users/{userId}") - public ResponseEntity> findUserDetail(@PathVariable Long userId) { + public ResponseEntity findUserDetail(@PathVariable Long userId) { AdminUserDetailResponse response = adminUserService.findUserDetail(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 회원 상태 변경 @PutMapping("/users/{userId}/status") - public ResponseEntity> changeStatus( + public ResponseEntity changeStatus( @PathVariable Long userId, @RequestBody @Valid StatusChangeRequest request) { adminUserService.changeStatus(userId, request.status()); - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.ok().build(); } // 회원 권한 변경 @PutMapping("/users/{userId}/roles") - public ResponseEntity> changeRole( + public ResponseEntity changeRole( @PathVariable Long userId, @RequestBody @Valid RoleChangeRequest request) { adminUserService.addUserRole(userId, request.role()); - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.ok().build(); } // 회원 삭제 (실제로는 상태 변경 처리) diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java b/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java index dea0265..1a47404 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/MypageController.java @@ -1,6 +1,5 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.ApiResponse; import com.nhnacademy.insightonauth.dto.mypage.MyInfoResponse; import com.nhnacademy.insightonauth.dto.mypage.PasswordChangeRequest; import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; @@ -32,16 +31,16 @@ public class MypageController { // 내 정보 조회 @GetMapping("/me") - public ResponseEntity> findMyInfo( + public ResponseEntity findMyInfo( @RequestHeader(name = X_USER_ID) @Valid Long userId) { MyInfoResponse response = myPageService.findMyInfo(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 내 정보 수정 @PutMapping("/me") - public ResponseEntity> updateMyInfo( + public ResponseEntity updateMyInfo( @RequestHeader(name = X_USER_ID) @Valid Long userId, @RequestBody @Valid MyInfoUpdateRequest request) { @@ -59,7 +58,7 @@ public ResponseEntity withdraw(@RequestHeader(name = X_USER_ID) @Valid Lon // 비밀번호 변경 @PutMapping("/me/password") - public ResponseEntity> changePassword( + public ResponseEntity changePassword( @RequestHeader(name = X_USER_ID) @Valid Long userId, @RequestBody @Valid PasswordChangeRequest request) { @@ -69,25 +68,25 @@ public ResponseEntity> changePassword( // 내 권한 목록 조회 @GetMapping("/me/roles") - public ResponseEntity>> findMyRoles( + public ResponseEntity> findMyRoles( @RequestHeader(name = X_USER_ID) @Valid Long userId) { List response = myPageService.findMyRoles(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 연동 소셜 계정 목록 @GetMapping("/me/oauths") - public ResponseEntity>> findMyOauths( + public ResponseEntity> findMyOauths( @RequestHeader(name = X_USER_ID) @Valid Long userId) { List response = myPageService.findMyOauths(userId); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } // 소셜 계정 신규 연동 @PostMapping("/me/oauths/{provider}") - public ResponseEntity> linkOauth( + public ResponseEntity linkOauth( @RequestHeader(name = X_USER_ID) @Valid Long userId, @PathVariable String provider, @RequestBody @Valid OauthLoginRequest request) { @@ -106,5 +105,4 @@ public ResponseEntity unlinkOauth( oauthService.delete(user, oauthId); return ResponseEntity.noContent().build(); } - } diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java index 3b1b2e0..803696a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java @@ -1,6 +1,5 @@ package com.nhnacademy.insightonauth.controller; -import com.nhnacademy.insightonauth.dto.*; import com.nhnacademy.insightonauth.dto.auth.*; import com.nhnacademy.insightonauth.dto.oauth.OauthLoginRequest; import com.nhnacademy.insightonauth.entity.Role; @@ -23,114 +22,113 @@ public class UserController { @PostMapping("/email/verify-request") public ResponseEntity sendEmailVerify(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { userService.emailVerifyRequest(emailVerifyRequest.email()); - return ResponseEntity.noContent().build(); } @PostMapping("/email/verify-confirm") - public ResponseEntity> emailCodeConfirm( + public ResponseEntity emailCodeConfirm( @RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { String verificationToken = userService.emailVerifyConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); - return ResponseEntity.ok(new ApiResponse<>(new EmailVerifyConfirmResponse(verificationToken))); + return ResponseEntity.ok(new EmailVerifyConfirmResponse(verificationToken)); } @PostMapping("/check-email") - public ResponseEntity> checkEmailAvailable( + public ResponseEntity checkEmailAvailable( @RequestBody @Valid EmailAvailableRequest emailAvailableRequest) { boolean available = userService.checkEmailAvailable(emailAvailableRequest.email()); - return ResponseEntity.ok(new ApiResponse<>(new EmailAvailableResponse(available))); + return ResponseEntity.ok(new EmailAvailableResponse(available)); } @PostMapping("/signup") - public ResponseEntity> doSignup( + public ResponseEntity doSignup( @RequestBody @Valid UserSignupRequest userSignupRequest) { UserSignupResponse userSignupResponse = userService.createUser(userSignupRequest.email(), - userSignupRequest.password(), - userSignupRequest.userName(), - userSignupRequest.phoneNumber(), - Role.MEMBER, - userSignupRequest.token()); - - return ResponseEntity.status(HttpStatus.CREATED) - .body(new ApiResponse<>(userSignupResponse)); + userSignupRequest.password(), + userSignupRequest.userName(), + userSignupRequest.phoneNumber(), + Role.MEMBER, + userSignupRequest.token()); + + return ResponseEntity.status(HttpStatus.CREATED).body(userSignupResponse); } @PostMapping("/login") - public ResponseEntity> doLogin( + public ResponseEntity doLogin( @RequestBody @Valid UserLoginRequest userLoginRequest) { UserLoginResponse userLoginResponse = userService.login(userLoginRequest.email(), userLoginRequest.password()); - return ResponseEntity.ok(new ApiResponse<>(userLoginResponse)); + return ResponseEntity.ok(userLoginResponse); } @PostMapping("/logout") public ResponseEntity doLogout(@RequestHeader(name = X_USER_ID) @Valid Long userId) { userService.logout(userId); - return ResponseEntity.noContent().build(); } + @PostMapping("/reactive") - public ResponseEntity> userReactive( + public ResponseEntity userReactive( @RequestBody @Valid ReactiveRequest request) { UserLoginResponse response = userService.reactive(request.reactiveToken()); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } @PostMapping("/reactivate/email-verify-request") - public ResponseEntity userReactive(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { + public ResponseEntity userReactivateRequest(@RequestBody @Valid EmailVerifyRequest emailVerifyRequest) { userService.reactivateRequest(emailVerifyRequest.email()); - return ResponseEntity.noContent().build(); } @PostMapping("/reactivate/email-verify-confirm") - public ResponseEntity> userReactiveConfirm(@RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { - UserLoginResponse userLoginResponse = userService.reactivateConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); + public ResponseEntity userReactiveConfirm( + @RequestBody @Valid EmailVerifyConfirmRequest emailVerifyConfirmRequest) { + UserLoginResponse userLoginResponse = + userService.reactivateConfirm(emailVerifyConfirmRequest.email(), emailVerifyConfirmRequest.code()); - return ResponseEntity.ok(new ApiResponse<>(userLoginResponse)); + return ResponseEntity.ok(userLoginResponse); } @PostMapping("/find-email") - public ResponseEntity> findEmail(@RequestBody @Valid FindEmailRequest findEmailRequest) { + public ResponseEntity findEmail(@RequestBody @Valid FindEmailRequest findEmailRequest) { String email = userService.findMaskedEmail(findEmailRequest.userName(), findEmailRequest.phoneNumber()); - return ResponseEntity.ok(new ApiResponse<>(email)); + return ResponseEntity.ok(email); } @PostMapping("/password/reset-request") - public ResponseEntity> passwordReset(@RequestBody @Valid PasswordResetRequest passwordResetRequest) { + public ResponseEntity passwordReset(@RequestBody @Valid PasswordResetRequest passwordResetRequest) { userService.passwordResetRequest(passwordResetRequest.email()); - - return ResponseEntity.ok(new ApiResponse<>(null)); + return ResponseEntity.noContent().build(); } @PostMapping("/password/reset-confirm") - public ResponseEntity passwordResetConfirm(@RequestBody @Valid PasswordResetConfirmRequest passwordResetConfirmRequest) { + public ResponseEntity passwordResetConfirm( + @RequestBody @Valid PasswordResetConfirmRequest passwordResetConfirmRequest) { userService.passwordResetConfirm(passwordResetConfirmRequest.token(), passwordResetConfirmRequest.password()); return ResponseEntity.ok().build(); } @PostMapping("/oauth/{provider}") - public ResponseEntity> oauthLogin( + public ResponseEntity oauthLogin( @PathVariable String provider, @RequestBody @Valid OauthLoginRequest request) { UserLoginResponse response = userService.oauthLogin(provider, request.code()); - return ResponseEntity.ok(new ApiResponse<>(response)); + return ResponseEntity.ok(response); } @PostMapping("/refresh") - public ResponseEntity> refresh( + public ResponseEntity refresh( @RequestHeader(name = X_USER_ID) @Valid Long userId, @CookieValue("refreshToken") String refreshToken) { TokenRefreshResponse tokenRefreshResponse = userService.refresh(userId, refreshToken); - return ResponseEntity.ok(new ApiResponse<>(tokenRefreshResponse)); + return ResponseEntity.ok(tokenRefreshResponse); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java deleted file mode 100644 index 5db731b..0000000 --- a/src/main/java/com/nhnacademy/insightonauth/dto/ApiResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.nhnacademy.insightonauth.dto; - -import lombok.Getter; - -@Getter -public class ApiResponse { - private final boolean success; - private final T data; - private final ErrorResponse error; - - // 성공 응답용 생성자 - public ApiResponse(T data) { - this.success = true; - this.data = data; - this.error = null; - } - - public ApiResponse(String code, String message) { - this.success = false; - this.data = null; - this.error = new ErrorResponse(code, message); - } -} diff --git a/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java b/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java deleted file mode 100644 index f44eba7..0000000 --- a/src/main/java/com/nhnacademy/insightonauth/dto/ErrorResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.nhnacademy.insightonauth.dto; - -public class ErrorResponse { - private final String code; - private final String message; - - public ErrorResponse(String code, String message) { - this.code = code; - this.message = message; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } -} From a0070af06bf3ccfb87214e6261551c83f606d428 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sat, 8 Aug 2026 23:17:59 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20Feign=20Circuit=20Breaker=20ID?= =?UTF-8?q?=EC=9D=98=20=ED=95=98=EC=9D=B4=ED=94=88=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index e46b8c7..a4fb320 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -91,6 +91,9 @@ oauth.github.client-secret=${GITHUB_CLIENT_SECRET} # =============================== spring.cloud.openfeign.circuitbreaker.enabled=true +# Feign이 만드는 Circuit Breaker ID에서 하이픈이 제거되지 않도록 명시 +spring.cloud.openfeign.circuitbreaker.alphanumeric-ids.enabled=false + # 오픈페인이랑 확인 필요 resilience4j.circuitbreaker.instances.insighton-core.sliding-window-size=10 resilience4j.circuitbreaker.instances.insighton-core.failure-rate-threshold=50 From ede8d8f1ae78ae9a797e0c580727da1c7edd20d7 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sun, 9 Aug 2026 10:47:21 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20HeaderAuthenticationFilter=20?= =?UTF-8?q?X-User-Role=20=ED=97=A4=EB=8D=94=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/HeaderAuthenticationFilter.java | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java index c6a7c08..05721b8 100644 --- a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java +++ b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java @@ -1,7 +1,5 @@ package com.nhnacademy.insightonauth.filter; -import com.nhnacademy.insightonauth.entity.User; -import com.nhnacademy.insightonauth.entity.UserRole; import com.nhnacademy.insightonauth.service.UserRoleService; import com.nhnacademy.insightonauth.service.UserService; import jakarta.servlet.FilterChain; @@ -16,10 +14,10 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.ArrayList; import java.util.List; @Slf4j @@ -33,18 +31,24 @@ public class HeaderAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String userIdHeader = request.getHeader("X-User-Id"); + String rolesHeader = request.getHeader("X-User-Role"); if (userIdHeader != null) { - Long userId = Long.valueOf(userIdHeader); - - User user = userService.findById(userId); - List userRoles = userRoleService.findByUser(user); - List authorities = userRoles.stream() - .map(userRole -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + userRole.getRole().name())) - .toList(); - - Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); + try { + Long userId = Long.valueOf(userIdHeader); + + List authorities = new ArrayList<>(); + if (rolesHeader != null && !rolesHeader.isBlank()) { + for (String role : rolesHeader.split(",")) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role.trim())); + } + } + + Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + log.debug("유효하지 않은 인증 헤더 - X-User-Id: {}, X-User-Role: {}", userIdHeader, rolesHeader); + } } filterChain.doFilter(request, response); From 2338e5f33bd530a06d7f1575f2f136897c1f4e05 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sun, 9 Aug 2026 16:00:41 +0900 Subject: [PATCH 10/17] =?UTF-8?q?test:=20User,=20UserCredential,=20UserRol?= =?UTF-8?q?e,=20Oauth=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../insightonauth/entity/OauthTest.java | 27 ++++++ .../entity/UserCredentialTest.java | 45 +++++++++ .../insightonauth/entity/UserRoleTest.java | 28 ++++++ .../insightonauth/entity/UserTest.java | 93 +++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java create mode 100644 src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java create mode 100644 src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java create mode 100644 src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java new file mode 100644 index 0000000..2c3f97d --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/OauthTest.java @@ -0,0 +1,27 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class OauthTest { + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("oauth 생성자 생성 성공") + void createOauth() { + Oauth oauth = new Oauth(user, "google", "provider-user-id-123"); + + assertThat(oauth.getUser()).isEqualTo(user); + assertThat(oauth.getProvider()).isEqualTo("google"); + assertThat(oauth.getProviderUserId()).isEqualTo("provider-user-id-123"); + assertThat(oauth.getCreatedAt()).isNotNull(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java new file mode 100644 index 0000000..e9d2282 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserCredentialTest.java @@ -0,0 +1,45 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class UserCredentialTest { + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("userCredential 생성자 생성 성공") + void createUserCredential() { + User user = new User("test@test.com", "test", "01012345678"); + UserCredential credential = new UserCredential(user, "hashed-password"); + + assertThat(credential.getUser()).isEqualTo(user); + assertThat(credential.getPasswordHash()).isEqualTo("hashed-password"); + assertThat(credential.getCreatedAt()).isNotNull(); + assertThat(credential.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("changePassword 비밀번호, 수정시각 변경") + void changePassword_updatesPasswordAndUpdatedAt() { + UserCredential credential = new UserCredential(user, "old-hashed-password"); + OffsetDateTime newTime = OffsetDateTime.now(ZoneOffset.UTC).plusDays(1); + + credential.changePassword(newTime, "new-hashed-password"); + + assertThat(credential.getPasswordHash()).isEqualTo("new-hashed-password"); + assertThat(credential.getUpdatedAt()).isEqualTo(newTime); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java new file mode 100644 index 0000000..cec8b11 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserRoleTest.java @@ -0,0 +1,28 @@ +package com.nhnacademy.insightonauth.entity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class UserRoleTest { + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + } + + @Test + @DisplayName("userRole 생성자 생성 성공") + void createUserRole() { + UserRole userRole = new UserRole(user, Role.MEMBER); + + assertThat(userRole.getUser()).isEqualTo(user); + assertThat(userRole.getRole()).isEqualTo(Role.MEMBER); + assertThat(userRole.getCreatedAt()).isNotNull(); + } + +} diff --git a/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java b/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java new file mode 100644 index 0000000..a7b143f --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/entity/UserTest.java @@ -0,0 +1,93 @@ +package com.nhnacademy.insightonauth.entity; + +import com.nhnacademy.insightonauth.exception.InvalidUserStatusException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class UserTest { + + @Test + @DisplayName("user 생성자 생성 성공") + void createUser() { + User user = new User("test@test.com", "test", "01012345678"); + + assertThat(user.getEmail()).isEqualTo("test@test.com"); + assertThat(user.getUserName()).isEqualTo("test"); + assertThat(user.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + } + + @Test + @DisplayName("withdraw시 접미사 확인") + void withdraw_suffix() { + User user = new User("test@test.com", "test", "01012345678"); + + user.withdraw(); + + assertThat(user.getEmail()).startsWith("test@test.com;"); + assertThat(user.getPhoneNumber()).startsWith("01012345678;"); + assertThat(user.getStatus()).isEqualTo(Status.WITHDRAW); + assertThat(user.getWithdrawnAt()).isNotNull(); + } + + @Test + @DisplayName("withdraw후 reactivate시 원복") + void withdraw_then_reactivate_restoresEmail() { + User user = new User("test@test.com", "test", "01012345678"); + user.withdraw(); + + user.reactivate(); + + assertThat(user.getEmail()).isEqualTo("test@test.com"); + assertThat(user.getPhoneNumber()).isEqualTo("01012345678"); + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + assertThat(user.getWithdrawnAt()).isNull(); + } + + @Test + @DisplayName("ACTIVE 상태에서 reactivate 호출 시 예외 발생") + void reactivate_whenActive_throwsException() { + User user = new User("test@test.com", "test", "01012345678"); + + assertThatThrownBy(user::reactivate) + .isInstanceOf(InvalidUserStatusException.class) + .hasMessage("휴면 또는 탈퇴 상태가 아닙니다."); + } + + @Test + @DisplayName("전화번호가 null이어도 withdraw 정상 동작") + void withdraw_withNullPhoneNumber_worksCorrectly() { + User user = new User("test@test.com", "test", null); + user.withdraw(); + + assertThat(user.getPhoneNumber()).isNull(); + assertThat(user.getStatus()).isEqualTo(Status.WITHDRAW); + } + + @Test + @DisplayName("SLEEP 상태에서 reactivate 호출 시 ACTIVE로 전환") + void reactivate_whenSleep_becomesActive() { + User user = new User("test@test.com", "test", "01012345678"); + user.setStatus(Status.SLEEP); + + user.reactivate(); + + assertThat(user.getStatus()).isEqualTo(Status.ACTIVE); + assertThat(user.getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("BLOCK 상태에서 reactivate 호출 시 예외 발생") + void reactivate_whenBlocked_throwsException() { + User user = new User("test@test.com", "test", "01012345678"); + user.setStatus(Status.BLOCK); + + assertThatThrownBy(user::reactivate) + .isInstanceOf(InvalidUserStatusException.class) + .hasMessage("휴면 또는 탈퇴 상태가 아닙니다."); + } + +} From 2a76b4757680dbd9c922dfdcbc4806fac06e15d4 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Sun, 9 Aug 2026 19:12:36 +0900 Subject: [PATCH 11/17] =?UTF-8?q?test:=20UserRepository,=20UserRoleReposit?= =?UTF-8?q?ory,=20UserCredentialRepository,=20OauthRepository=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/OauthRepositoryTest.java | 104 +++++++++++ .../UserCredentialRepositoryTest.java | 73 ++++++++ .../repository/UserRepositoryTest.java | 169 ++++++++++++++++++ .../repository/UserRoleRepositoryTest.java | 76 ++++++++ 4 files changed, 422 insertions(+) create mode 100644 src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java create mode 100644 src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java create mode 100644 src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java create mode 100644 src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java new file mode 100644 index 0000000..7b69b7f --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java @@ -0,0 +1,104 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Oauth; +import com.nhnacademy.insightonauth.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + + +@DataJpaTest +class OauthRepositoryTest { + + @Autowired + private OauthRepository oauthRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + userRepository.save(user); + oauthRepository.save(new Oauth(user, "google", "google-provider-id-123")); + } + + @Test + @DisplayName("user, provider로 연동 정보 조회") + void findByUserAndProvider_returnsOauth() { + Optional found = oauthRepository.findByUserAndProvider(user, "google"); + + assertThat(found) + .isPresent() + .get() + .extracting(Oauth::getProviderUserId) + .isEqualTo("google-provider-id-123"); + } + + @Test + @DisplayName("연동 없는 provider 조회 시 빈 값 반환") + void findByUserAndProvider_whenNotExists_returnsEmpty() { + Optional found = oauthRepository.findByUserAndProvider(user, "github"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("provider, providerUserId로 연동 정보 조회") + void findByProviderAndProviderUserId_returnsOauth() { + Optional found = oauthRepository.findByProviderAndProviderUserId("google", "google-provider-id-123"); + + assertThat(found) + .isPresent() + .get() + .extracting(oauth -> oauth.getUser().getUserId()) + .isEqualTo(user.getUserId()); + } + + @Test + @DisplayName("없는 providerUserId 조회 시 빈 값 반환") + void findByProviderAndProviderUserId_whenNotExists_returnsEmpty() { + Optional found = oauthRepository.findByProviderAndProviderUserId("google", "not-exist-id"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("user로 연동 목록 조회") + void findByUser_returnsOauthList() { + List result = oauthRepository.findByUser(user); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getProvider()).isEqualTo("google"); + } + + @Test + @DisplayName("user로 연동 개수 조회") + void countByUser_returnsCount() { + oauthRepository.save(new Oauth(user, "github", "github-provider-id-456")); + + Long count = oauthRepository.countByUser(user); + + assertThat(count).isEqualTo(2L); + } + + @Test + @DisplayName("user로 연동 정보 전체 삭제") + void deleteByUser_removesAllOauths() { + oauthRepository.save(new Oauth(user, "github", "github-provider-id-456")); + + oauthRepository.deleteByUser(user); + + List result = oauthRepository.findByUser(user); + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java new file mode 100644 index 0000000..11e3820 --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java @@ -0,0 +1,73 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.entity.UserCredential; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +class UserCredentialRepositoryTest { + @Autowired + private UserCredentialRepository userCredentialRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + userRepository.save(user); + userCredentialRepository.save(new UserCredential(user, "hashed-password")); + } + + @Test + @DisplayName("user로 존재 여부 확인 - true") + void existsByUser_returnsTrueWhenExists() { + boolean exists = userCredentialRepository.existsByUser(user); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("user로 존재 여부 확인 - false") + void existsByUser_returnsFalseWhenNotExists() { + User otherUser = new User("other@test.com", "other", "01099998888"); + userRepository.save(otherUser); + + boolean exists = userCredentialRepository.existsByUser(otherUser); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("user로 인증 정보 조회") + void findByUser_returnsUserCredential() { + Optional found = userCredentialRepository.findByUser(user); + + assertThat(found) + .isPresent() + .get() + .extracting(UserCredential::getPasswordHash) + .isEqualTo("hashed-password"); + } + + @Test + @DisplayName("인증 정보 없는 user 조회 시 빈 값 반환") + void findByUser_whenNotExists_returnsEmpty() { + User otherUser = new User("other@test.com", "other", "01099998888"); + userRepository.save(otherUser); + + Optional found = userCredentialRepository.findByUser(otherUser); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java new file mode 100644 index 0000000..36fb64b --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java @@ -0,0 +1,169 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Status; +import com.nhnacademy.insightonauth.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private User activeUser; + + @BeforeEach + void setUp() { + activeUser = new User("test@test.com", "test", "01012345678"); + userRepository.save(activeUser); + } + + @Test + @DisplayName("email로 유저 조회") + void findByEmail_returnsUser() { + Optional found = userRepository.findByEmail("test@test.com"); + + assertThat(found).isPresent(); + assertThat(found.get().getUserName()).isEqualTo("test"); + } + + @Test + @DisplayName("없는 email 조회 시 빈 값 반환") + void findByEmail_whenNotExists_returnsEmpty() { + Optional found = userRepository.findByEmail("notfound@test.com"); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("userName, phoneNumber로 유저 조회") + void findByUserNameAndPhoneNumber_returnsUser() { + Optional found = userRepository.findByUserNameAndPhoneNumber("test", "01012345678"); + + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("탈퇴 email 접두어, status로 유저 조회") + void findByEmailStartingWithAndStatus_returnsUser() { + activeUser.withdraw(); + userRepository.save(activeUser); + String originalEmailPrefix = "test@test.com;"; + + Optional found = userRepository.findByEmailStartingWithAndStatus(originalEmailPrefix, Status.WITHDRAW); + + assertThat(found).isPresent(); + } + + @Test + @DisplayName("email, userName, status로 페이징 조회") + void findByEmailContainingAndUserNameContainingAndStatus_returnsPage() { + Pageable pageable = PageRequest.of(0, 10); + + Page result = userRepository.findByEmailContainingAndUserNameContainingAndStatus( + "test", "test", Status.ACTIVE, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().getEmail()).isEqualTo("test@test.com"); + } + + @Test + @DisplayName("email, userName으로 페이징 조회") + void findByEmailContainingAndUserNameContaining_returnsPage() { + Pageable pageable = PageRequest.of(0, 10); + + Page result = userRepository.findByEmailContainingAndUserNameContaining( + "test", "test", pageable); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + @DisplayName("email 존재 여부 확인 - true") + void existsByEmail_returnsTrueWhenExists() { + boolean exists = userRepository.existsByEmail("test@test.com"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("email 존재 여부 확인 - false") + void existsByEmail_returnsFalseWhenNotExists() { + boolean exists = userRepository.existsByEmail("notfound@test.com"); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("phoneNumber 존재 여부 확인") + void existsByPhoneNumber_returnsTrueWhenExists() { + boolean exists = userRepository.existsByPhoneNumber("01012345678"); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("탈퇴 90일 경과 유저 조회") + void findByStatusAndWithdrawnAtBefore_returnsExpiredWithdrawnUsers() { + activeUser.withdraw(); + activeUser.setWithdrawnAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(100)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("탈퇴 90일 미경과 유저 제외") + void findByStatusAndWithdrawnAtBefore_excludesRecentWithdrawnUsers() { + activeUser.withdraw(); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndWithdrawnAtBefore( + Status.WITHDRAW, OffsetDateTime.now(ZoneOffset.UTC).minusDays(90)); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("30일 미접속 유저 조회") + void findByStatusAndLastLoginAtBefore_returnsInactiveUsers() { + activeUser.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(40)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("최근 접속 유저 제외") + void findByStatusAndLastLoginAtBefore_excludesRecentlyActiveUsers() { + activeUser.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + userRepository.save(activeUser); + + List result = userRepository.findByStatusAndLastLoginAtBefore( + Status.ACTIVE, OffsetDateTime.now(ZoneOffset.UTC).minusDays(30)); + + assertThat(result).isEmpty(); + } +} + diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java new file mode 100644 index 0000000..c5ed64b --- /dev/null +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java @@ -0,0 +1,76 @@ +package com.nhnacademy.insightonauth.repository; + +import com.nhnacademy.insightonauth.entity.Role; +import com.nhnacademy.insightonauth.entity.User; +import com.nhnacademy.insightonauth.entity.UserRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +class UserRoleRepositoryTest { + + @Autowired + private UserRoleRepository userRoleRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = new User("test@test.com", "test", "01012345678"); + userRepository.save(user); + userRoleRepository.save(new UserRole(user, Role.MEMBER)); + } + + @Test + @DisplayName("user로 권한 목록 조회") + void findByUser_returnsRoles() { + List result = userRoleRepository.findByUser(user); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getRole()).isEqualTo(Role.MEMBER); + } + + @Test + @DisplayName("user, role로 존재 여부 확인 - true") + void existsByUserAndRole_returnsTrueWhenExists() { + boolean exists = userRoleRepository.existsByUserAndRole(user, Role.MEMBER); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("user, role로 존재 여부 확인 - false") + void existsByUserAndRole_returnsFalseWhenNotExists() { + boolean exists = userRoleRepository.existsByUserAndRole(user, Role.ADMIN); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("user, role로 권한 조회") + void findByUserAndRole_returnsUserRole() { + Optional found = userRoleRepository.findByUserAndRole(user, Role.MEMBER); + + assertThat(found).isPresent(); + assertThat(found.get().getUser()).isEqualTo(user); + } + + @Test + @DisplayName("없는 role로 조회 시 빈 값 반환") + void findByUserAndRole_whenNotExists_returnsEmpty() { + Optional found = userRoleRepository.findByUserAndRole(user, Role.ADMIN); + + assertThat(found).isEmpty(); + } +} From 284b984defb1dd07e3e5023a2a1183b2139243ac Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Wed, 12 Aug 2026 16:01:18 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=EB=B6=84=EC=82=B0=20=EB=9D=BD?= =?UTF-8?q?=EC=9D=84=20Redisson=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?=ED=95=B4=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 7 ++++ .../insightonauth/redis/RedisService.java | 6 ---- .../scheduler/UserHardDeleteScheduler.java | 28 ++++++++++++---- .../UserSleepConversionScheduler.java | 32 +++++++++++++++---- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/pom.xml b/pom.xml index 3eb9445..281a46c 100644 --- a/pom.xml +++ b/pom.xml @@ -148,6 +148,13 @@ micrometer-registry-prometheus + + + org.redisson + redisson-spring-boot-starter + 3.52.0 + + org.springframework.cloud spring-cloud-config-client diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java index 91b8001..daff191 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisService.java @@ -27,10 +27,4 @@ public void delete(String key) { public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } - - // 수정 해야함 - public boolean setIfAbsent(String key, String value, Duration ttl) { - Boolean result = redisTemplate.opsForValue().setIfAbsent(key, value, ttl); - return Boolean.TRUE.equals(result); - } } diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java index 96161d1..96a491a 100644 --- a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java @@ -2,15 +2,16 @@ import com.nhnacademy.insightonauth.entity.User; import com.nhnacademy.insightonauth.redis.RedisKey; -import com.nhnacademy.insightonauth.redis.RedisService; import com.nhnacademy.insightonauth.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.Duration; import java.util.List; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -18,12 +19,22 @@ public class UserHardDeleteScheduler { private final UserService userService; - private final RedisService redisService; + private final RedissonClient redissonClient; - @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 1 * * *") // 매일 새벽 1시 public void hardDeleteExpiredUsers() { - String lockKey = RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix(); - boolean acquired = redisService.setIfAbsent(lockKey, "locked", Duration.ofMinutes(30)); + RLock lock = redissonClient.getLock(RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + + boolean acquired; + try { + // waitTime=0 : 이미 남이 잡았으면 기다리지 않고 즉시 스킵 + // leaseTime=-1 : 워치독 활성화 (작업이 끝날 때까지 TTL 자동 갱신) + acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("하드 삭제 락 획득 중 인터럽트 발생"); + return; + } if (!acquired) { log.info("다른 인스턴스가 이미 이 작업을 실행 중입니다. 건너뜁니다."); @@ -43,7 +54,10 @@ public void hardDeleteExpiredUsers() { log.info("탈퇴 계정 물리 삭제 완료 - 대상 {}건", targets.size()); } finally { - redisService.delete(lockKey); + // 내가 쥔 락일 때만 해제 (남의 락/이미 만료된 락은 건드리지 않음) + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } } } diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java index 5787175..70d10ed 100644 --- a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java @@ -2,15 +2,16 @@ import com.nhnacademy.insightonauth.entity.User; import com.nhnacademy.insightonauth.redis.RedisKey; -import com.nhnacademy.insightonauth.redis.RedisService; import com.nhnacademy.insightonauth.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.Duration; import java.util.List; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -18,12 +19,26 @@ public class UserSleepConversionScheduler { private final UserService userService; - private final RedisService redisService; + private final RedissonClient redissonClient; - @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 4시 + @Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 public void convertInactiveUsersToSleep() { - String lockKey = RedisKey.SLEEP_CONVERSION_SCHEDULER_LOCK.getPrefix(); - boolean acquired = redisService.setIfAbsent(lockKey, "locked", Duration.ofMinutes(30)); + // 이중화된 여러 인스턴스가 동시에 이 배치를 실행하지 않도록 Redisson 분산 락 사용. + // 워치독이 작업 시간에 맞춰 락 TTL을 자동 갱신하므로, 배치가 오래 걸려도 + // 락이 만료돼 다른 인스턴스가 끼어드는 일이 없고, 소유권 확인 해제로 남의 락 삭제도 방지됨. + // 락 객체(핸들) 획득 — 실제 잠금은 tryLock에서 수행 + RLock lock = redissonClient.getLock(RedisKey.SLEEP_CONVERSION_SCHEDULER_LOCK.getPrefix()); + + boolean acquired; + try { + // waitTime=0 : 이미 다른 인스턴스가 잡았으면 대기 없이 즉시 실패(false) → 스킵 + // leaseTime=-1: 워치독 활성화. 고정 만료 대신, 인스턴스가 살아있는 동안 TTL을 자동 갱신 + acquired = lock.tryLock(0, -1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("휴면 전환 락 획득 중 인터럽트 발생"); + return; + } if (!acquired) { log.info("다른 인스턴스가 이미 휴면 전환 작업을 실행 중입니다. 건너뜁니다."); @@ -44,7 +59,10 @@ public void convertInactiveUsersToSleep() { log.info("휴면 전환 완료 - 대상 {}건", targets.size()); } finally { - redisService.delete(lockKey); + // 내가 쥔 락일 때만 해제 (혹시 만료로 소유권이 넘어갔으면 건드리지 않음 → 예외/남의 락 삭제 방지) + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } } } From c341f66360651faacc048b9fe95af148a4145f17 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Wed, 12 Aug 2026 16:01:31 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20OAuth=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20lastLoginAt=20=EB=AF=B8=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/impl/GithubOauthClient.java | 14 ++++++++++++-- .../com/nhnacademy/insightonauth/entity/User.java | 5 ++++- .../insightonauth/service/UserService.java | 2 -- .../service/impl/UserServiceImpl.java | 11 ++++------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java index 3261be2..93526e2 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java @@ -5,12 +5,15 @@ import com.nhnacademy.insightonauth.exception.EmailNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; +import java.time.Duration; import java.util.List; import java.util.Map; @@ -27,7 +30,12 @@ public class GithubOauthClient implements OauthClient { @Value("${oauth.redirect-uri}") private String redirectUri; - private final RestClient restClient = RestClient.create(); + private final RestClient restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactoryBuilder.detect() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(Duration.ofSeconds(3)) + .withReadTimeout(Duration.ofSeconds(5)))) + .build(); @Override public OauthUserInfo getUserInfo(String code) { @@ -87,8 +95,10 @@ private String requestPrimaryEmail(String accessToken) { .retrieve() .body(List.class); + // primary git에서 대표로 지정된 이메일 가져옴, verified 그 중에 인증된거 가져옴 return emails.stream() - .filter(e -> Boolean.TRUE.equals(e.get("primary"))) + .filter(e -> + Boolean.TRUE.equals(e.get("primary")) && Boolean.TRUE.equals(e.get("verified"))) .map(e -> (String) e.get("email")) .findFirst() .orElseThrow(() -> new EmailNotFoundException("GitHub 계정에서 이메일을 찾을 수 없습니다.")); diff --git a/src/main/java/com/nhnacademy/insightonauth/entity/User.java b/src/main/java/com/nhnacademy/insightonauth/entity/User.java index 040661c..514d475 100644 --- a/src/main/java/com/nhnacademy/insightonauth/entity/User.java +++ b/src/main/java/com/nhnacademy/insightonauth/entity/User.java @@ -40,7 +40,6 @@ public class User { @Column(name = "status", nullable = false, length = 20) private Status status; - @Setter @Column(name = "last_login_at", nullable = true) private OffsetDateTime lastLoginAt; @@ -66,6 +65,10 @@ public User(String email, String userName, String phoneNumber) { this.createdAt = now; } + public void updateLastLoginAt() { + this.lastLoginAt = OffsetDateTime.now(ZoneOffset.UTC); + } + public void reactivate() { if (this.status != Status.SLEEP && this.status != Status.WITHDRAW) { throw new InvalidUserStatusException("휴면 또는 탈퇴 상태가 아닙니다."); diff --git a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java index 1fe67bf..df64a93 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/UserService.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/UserService.java @@ -47,8 +47,6 @@ public interface UserService { String findMaskedEmail(String userName, String phoneNumber); - void updateLastLoginAt(Long userId); - void activate(Long userId); void withdraw(Long userId); diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index ed0d630..e2fed57 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -27,6 +27,7 @@ import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -129,7 +130,7 @@ public UserLoginResponse login(String email, String password) { throw new InvalidUserException(user.getStatus().getMessage()); } - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + user.updateLastLoginAt(); return issueTokens(user, email); } @@ -246,12 +247,6 @@ public String findMaskedEmail(String userName, String phoneNumber) { return visible + masked + domain; } - @Override - public void updateLastLoginAt(Long userId) { - User user = findById(userId); - user.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); - } - @Override public void activate(Long userId) { User user = findById(userId); @@ -350,6 +345,7 @@ public UserLoginResponse oauthLogin(String provider, String code) { throw new InvalidUserException(user.getStatus().getMessage()); } + user.updateLastLoginAt(); return issueTokens(user, user.getEmail()); } @@ -357,6 +353,7 @@ public UserLoginResponse oauthLogin(String provider, String code) { userRepository.save(newUser); userRoleService.create(newUser, Role.MEMBER); oauthService.create(newUser, provider, userInfo.providerId()); + newUser.updateLastLoginAt(); return issueTokens(newUser, userInfo.email()); } From 5ec222e2b3698e033a7545cbc8eca5cd298baeaa Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Wed, 12 Aug 2026 16:01:37 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20Resilience4j=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=81=EC=9A=A9=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.properties | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index a4fb320..b00428e 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -91,10 +91,8 @@ oauth.github.client-secret=${GITHUB_CLIENT_SECRET} # =============================== spring.cloud.openfeign.circuitbreaker.enabled=true -# Feign이 만드는 Circuit Breaker ID에서 하이픈이 제거되지 않도록 명시 -spring.cloud.openfeign.circuitbreaker.alphanumeric-ids.enabled=false - -# 오픈페인이랑 확인 필요 -resilience4j.circuitbreaker.instances.insighton-core.sliding-window-size=10 -resilience4j.circuitbreaker.instances.insighton-core.failure-rate-threshold=50 -resilience4j.circuitbreaker.instances.insighton-core.wait-duration-in-open-state=10s +# Feign이 생성하는 CB 이름(CoreClient#메서드(파라미터))과 인스턴스 이름 매칭이 +# 까다로워, 이름 무관하게 적용되는 configs.default로 공통 정책 설정 +resilience4j.circuitbreaker.configs.default.sliding-window-size=10 +resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50 +resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s \ No newline at end of file From bec00e57a5ec6c371919534b03cf4b463a0e4256 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Wed, 12 Aug 2026 16:24:14 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20rebase=20=ED=9B=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(Redisson=20AUTH,=20lastLoginAt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/nhnacademy/insightonauth/entity/User.java | 4 ++++ .../insightonauth/repository/UserRepositoryTest.java | 4 ++-- src/test/resources/application.properties | 7 +++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/nhnacademy/insightonauth/entity/User.java b/src/main/java/com/nhnacademy/insightonauth/entity/User.java index 514d475..8a89dd0 100644 --- a/src/main/java/com/nhnacademy/insightonauth/entity/User.java +++ b/src/main/java/com/nhnacademy/insightonauth/entity/User.java @@ -69,6 +69,10 @@ public void updateLastLoginAt() { this.lastLoginAt = OffsetDateTime.now(ZoneOffset.UTC); } + public void updateLastLoginAt(OffsetDateTime lastLoginAt) { // 테스트/특정 시각 지정용 + this.lastLoginAt = lastLoginAt; + } + public void reactivate() { if (this.status != Status.SLEEP && this.status != Status.WITHDRAW) { throw new InvalidUserStatusException("휴면 또는 탈퇴 상태가 아닙니다."); diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java index 36fb64b..ab92e16 100644 --- a/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRepositoryTest.java @@ -145,7 +145,7 @@ void findByStatusAndWithdrawnAtBefore_excludesRecentWithdrawnUsers() { @Test @DisplayName("30일 미접속 유저 조회") void findByStatusAndLastLoginAtBefore_returnsInactiveUsers() { - activeUser.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(40)); + activeUser.updateLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC).minusDays(40)); userRepository.save(activeUser); List result = userRepository.findByStatusAndLastLoginAtBefore( @@ -157,7 +157,7 @@ void findByStatusAndLastLoginAtBefore_returnsInactiveUsers() { @Test @DisplayName("최근 접속 유저 제외") void findByStatusAndLastLoginAtBefore_excludesRecentlyActiveUsers() { - activeUser.setLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); + activeUser.updateLastLoginAt(OffsetDateTime.now(ZoneOffset.UTC)); userRepository.save(activeUser); List result = userRepository.findByStatusAndLastLoginAtBefore( diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index ed595a8..84e56da 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -31,7 +31,6 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect # =============================== spring.data.redis.host=localhost spring.data.redis.port=6379 -spring.data.redis.password= spring.data.redis.database=0 # =============================== @@ -90,6 +89,6 @@ oauth.github.client-secret=GITHUB_CLIENT_SECRET_TEST # =============================== spring.cloud.openfeign.circuitbreaker.enabled=true -resilience4j.circuitbreaker.instances.insighton-core.sliding-window-size=10 -resilience4j.circuitbreaker.instances.insighton-core.failure-rate-threshold=50 -resilience4j.circuitbreaker.instances.insighton-core.wait-duration-in-open-state=10s +resilience4j.circuitbreaker.configs.default.sliding-window-size=10 +resilience4j.circuitbreaker.configs.default.failure-rate-threshold=50 +resilience4j.circuitbreaker.configs.default.wait-duration-in-open-state=10s From 8e216e2f35a1fcc9e30b25effafa4206453af743 Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Wed, 12 Aug 2026 16:58:07 +0900 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20OAuth=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../insightonauth/client/impl/GithubOauthClient.java | 5 +---- .../exception/EmailAlreadyRegisteredException.java | 7 +++++++ .../com/nhnacademy/insightonauth/exception/ErrorCode.java | 3 ++- .../insightonauth/scheduler/UserHardDeleteScheduler.java | 8 +++++++- .../scheduler/UserSleepConversionScheduler.java | 8 +++++++- .../insightonauth/service/impl/UserServiceImpl.java | 7 +++++++ 6 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java diff --git a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java index 93526e2..882b302 100644 --- a/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java +++ b/src/main/java/com/nhnacademy/insightonauth/client/impl/GithubOauthClient.java @@ -68,10 +68,7 @@ private OauthUserInfo requestUserInfo(String accessToken) { .retrieve() .body(Map.class); - String email = (String) userInfo.get("email"); - if (email == null) { - email = requestPrimaryEmail(accessToken); // 비공개 이메일 대응 - } + String email = requestPrimaryEmail(accessToken); String name = (String) userInfo.get("name"); if (name == null || name.isBlank()) { diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java b/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java new file mode 100644 index 0000000..7bfb50a --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/EmailAlreadyRegisteredException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class EmailAlreadyRegisteredException extends BusinessException { + public EmailAlreadyRegisteredException(String message) { + super(ErrorCode.EMAIL_ALREADY_REGISTERED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java index 4284e7e..723bf5f 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java @@ -26,7 +26,8 @@ public enum ErrorCode { USER_CREDENTIALS_NOT_FOUND(HttpStatus.NOT_FOUND), USER_NOT_FOUND(HttpStatus.NOT_FOUND), USER_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND), - VERIFICATION_TEMPORARILY_LOCKED(HttpStatus.LOCKED); + VERIFICATION_TEMPORARILY_LOCKED(HttpStatus.LOCKED), + EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT); private final HttpStatus status; diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java index 96a491a..1fba30b 100644 --- a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserHardDeleteScheduler.java @@ -56,7 +56,13 @@ public void hardDeleteExpiredUsers() { } finally { // 내가 쥔 락일 때만 해제 (남의 락/이미 만료된 락은 건드리지 않음) if (lock.isHeldByCurrentThread()) { - lock.unlock(); + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + // 확인~해제 사이 워치독 갱신 실패로 락이 만료돼 소유권을 잃은 경우 + log.warn("락 해제 실패 - 이미 소유권을 상실함 (TTL 만료 추정). lockKey={}", + RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + } } } } diff --git a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java index 70d10ed..1b1cda2 100644 --- a/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java +++ b/src/main/java/com/nhnacademy/insightonauth/scheduler/UserSleepConversionScheduler.java @@ -61,7 +61,13 @@ public void convertInactiveUsersToSleep() { } finally { // 내가 쥔 락일 때만 해제 (혹시 만료로 소유권이 넘어갔으면 건드리지 않음 → 예외/남의 락 삭제 방지) if (lock.isHeldByCurrentThread()) { - lock.unlock(); + try { + lock.unlock(); + } catch (IllegalMonitorStateException e) { + // 확인~해제 사이 워치독 갱신 실패로 락이 만료돼 소유권을 잃은 경우 + log.warn("락 해제 실패 - 이미 소유권을 상실함 (TTL 만료 추정). lockKey={}", + RedisKey.HARD_DELETE_SCHEDULER_LOCK.getPrefix()); + } } } } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index e2fed57..3c0aa71 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -349,6 +349,13 @@ public UserLoginResponse oauthLogin(String provider, String code) { return issueTokens(user, user.getEmail()); } + // 새 User 만들기 전에, 이 이메일이 이미 가입돼 있는지 확인 + if (userRepository.existsByEmail(userInfo.email())) { + // 이미 이 이메일로 가입된 계정이 있음 → 자동 생성/연결하지 않고 차단 + throw new EmailAlreadyRegisteredException( + "이미 가입된 이메일입니다. 로그인 후 마이페이지에서 소셜 계정을 연동해 주세요."); + } + User newUser = new User(userInfo.email(), userInfo.name(), null); userRepository.save(newUser); userRoleService.create(newUser, Role.MEMBER); From 6aef4f364310e4098a478b691fd7c5ae39899eef Mon Sep 17 00:00:00 2001 From: Jungeunsun565 Date: Wed, 12 Aug 2026 17:09:44 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20Dockerfile=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index dbf4550..2340f1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN mvn clean package -DskipTests -B FROM eclipse-temurin:21-jre +ENV TZ=Asia/Seoul + RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*