From e68894f0c53fb1ba7650651c35ed0bf8ac0e3ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A4=80?= Date: Sun, 10 Aug 2025 14:18:42 +0900 Subject: [PATCH] =?UTF-8?q?[REFACTOR]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=9D=91=EB=8B=B5=20=ED=8F=AC=EB=A7=B7,?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=EA=B0=95=ED=99=94=20#79?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 45 ++++++++++++++++--- .../dto/request/KakaoLoginRequestDTO.java | 5 +++ .../user/dto/request/LoginRequestDTO.java | 4 ++ .../user/dto/request/SignupRequestDTO.java | 8 ++++ .../user/dto/response/LoginResponseDTO.java | 7 +++ .../user/dto/response/SignupResponseDTO.java | 5 +++ .../service/AuthService/KakaoAuthService.java | 29 +++++++++--- .../UserService/RefreshTokenService.java | 21 ++++++--- .../UserService/UserAuthServiceImpl.java | 23 ++++------ .../global/api/code/status/ErrorStatus.java | 13 ++++++ .../server/global/client/KakaoApiClient.java | 37 ++++++++++----- .../global/config/KakaoClientConfig.java | 31 +++++++++++++ 12 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/indayvidual/server/global/config/KakaoClientConfig.java diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java b/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java index 79f5605..7326aa3 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/AuthController.java @@ -9,8 +9,11 @@ import com.indayvidual.server.domain.user.service.UserService.RefreshTokenService; import com.indayvidual.server.domain.user.service.UserService.UserAuthService; import com.indayvidual.server.global.api.response.ApiResponse; +import com.indayvidual.server.global.exception.GeneralException; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; +import com.indayvidual.server.global.api.code.status.ErrorStatus; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -27,32 +30,51 @@ public class AuthController { private final KakaoAuthService kakaoAuthService; private final RefreshTokenService refreshTokenService; + @Operation( + summary = "회원가입", + description = "이메일과 비밀번호, 이름, 전화번호를 입력하여 회원가입을 진행합니다. 이미 가입된 이메일인 경우 실패합니다." + ) @PostMapping("/signup") - public ResponseEntity> signup(@RequestBody SignupRequestDTO request) { + public ResponseEntity> signup(@RequestBody @Valid SignupRequestDTO request) { SignupResponseDTO response = userAuthService.signupWithEmail(request); return ResponseEntity.ok(ApiResponse.onSuccess(response)); } + @Operation( + summary = "로그인", + description = "이메일과 비밀번호를 사용하여 로그인합니다. 성공 시 Access Token과 Refresh Token을 발급합니다." + ) @PostMapping("/login") - public ResponseEntity> login(@RequestBody LoginRequestDTO request) { + public ResponseEntity> login(@RequestBody @Valid LoginRequestDTO request) { LoginResponseDTO response = userAuthService.loginWithEmailAndPassword( request.getEmail(), request.getPassword() ); return ResponseEntity.ok(ApiResponse.onSuccess(response)); } + @Operation( + summary = "카카오 로그인", + description = "카카오 OAuth 액세스 토큰을 이용해 로그인합니다. 기존 회원이면 로그인, 아니면 자동 회원가입 후 로그인 처리됩니다." + ) @PostMapping("/kakao") public ResponseEntity> kakaoLogin( - @RequestBody KakaoLoginRequestDTO req) { + @RequestBody @Valid KakaoLoginRequestDTO req) { LoginResponseDTO dto = kakaoAuthService.loginWithKakao(req.getAccessToken()); return ResponseEntity.ok(ApiResponse.onSuccess(dto)); } + @Operation( + summary = "Access Token 재발급", + description = "Refresh Token을 이용해 새로운 Access Token과 Refresh Token을 발급합니다. 만료되거나 유효하지 않은 Refresh Token은 거부됩니다." + ) @PostMapping("/refresh") public ResponseEntity> refresh( - @RequestHeader("Refresh-Token") String refreshToken + @RequestHeader(value = "Refresh-Token", required = false) String refreshToken ) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new GeneralException(ErrorStatus.AUTH_REFRESH_MISSING); + } if (refreshToken.startsWith("Bearer ")) { refreshToken = refreshToken.substring(7); } @@ -60,11 +82,20 @@ public ResponseEntity> refresh( return ResponseEntity.ok(ApiResponse.onSuccess(dto)); } + @Operation( + summary = "로그아웃", + description = "Refresh Token을 사용하여 로그아웃 처리합니다. 해당 토큰은 폐기되며 재사용할 수 없습니다." + ) @PostMapping("/logout") public ResponseEntity> logout( - @RequestHeader("Authorization") String bearerToken + @RequestHeader(value = "Refresh-Token", required = false) String refreshToken ) { - String refreshToken = bearerToken.replace("Bearer ", ""); + if (refreshToken == null || refreshToken.isBlank()) { + throw new GeneralException(ErrorStatus.AUTH_REFRESH_MISSING); + } + if (refreshToken.startsWith("Bearer ")) { + refreshToken = refreshToken.substring(7); + } userAuthService.logoutWithRefreshToken(refreshToken); return ResponseEntity.ok(ApiResponse.onSuccess("로그아웃 완료")); } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/KakaoLoginRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/KakaoLoginRequestDTO.java index c010d2e..f10525f 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/KakaoLoginRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/KakaoLoginRequestDTO.java @@ -1,11 +1,16 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.Getter; import lombok.Setter; @Getter @Setter public class KakaoLoginRequestDTO { + + @Schema(description = "카카오 OAuth 액세스 토큰", example = "AAAAQwAAABBh...kaKaoAccessTokenSample", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "accessToken은 필수입니다.") private String accessToken; } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/LoginRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/LoginRequestDTO.java index 53d587a..55d7455 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/LoginRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/LoginRequestDTO.java @@ -1,5 +1,6 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; @@ -12,10 +13,13 @@ @NoArgsConstructor @AllArgsConstructor public class LoginRequestDTO { + + @Schema(description = "로그인 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "이메일은 필수입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") private String email; + @Schema(description = "로그인 비밀번호", example = "P@ssw0rd123!", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "비밀번호는 필수입니다.") private String password; } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java index 3fbfce9..d516de3 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/SignupRequestDTO.java @@ -1,7 +1,9 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; @@ -13,16 +15,22 @@ @NoArgsConstructor @AllArgsConstructor public class SignupRequestDTO { + + @Schema(description = "회원가입 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "이메일은 필수입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") private String email; + @Schema(description = "회원가입 비밀번호(8자 이상)", example = "P@ssw0rd123!", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "비밀번호는 필수입니다.") @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") private String password; + @Schema(description = "사용자 이름", example = "박성준", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "이름은 필수입니다.") private String username; + @Schema(description = "전화번호(선택)", example = "010-1234-5678") + @Pattern(regexp = "^01[0-9]-?\\d{3,4}-?\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.") private String phoneNumber; } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/response/LoginResponseDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/response/LoginResponseDTO.java index 78e2004..14e8335 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/response/LoginResponseDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/response/LoginResponseDTO.java @@ -1,5 +1,6 @@ package com.indayvidual.server.domain.user.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @Getter @@ -8,10 +9,16 @@ @AllArgsConstructor @NoArgsConstructor public class LoginResponseDTO { + @Schema(description = "JWT 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.TOKEN_SAMPLE") private String accessToken; + @Schema(description = "JWT 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0In0.REFRESH_SAMPLE") private String refreshToken; + @Schema(description = "사용자 ID", example = "1") private Long userId; + @Schema(description = "사용자 이메일", example = "user@example.com") private String email; + @Schema(description = "사용자 이름", example = "박성준") private String username; + @Schema(description = "역할(권한)", example = "ROLE_USER") private String role; } \ No newline at end of file diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/response/SignupResponseDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/response/SignupResponseDTO.java index 2e4163c..0460832 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/response/SignupResponseDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/response/SignupResponseDTO.java @@ -1,5 +1,6 @@ package com.indayvidual.server.domain.user.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -8,8 +9,12 @@ @Builder @AllArgsConstructor public class SignupResponseDTO { + @Schema(description = "생성된 사용자 ID", example = "1") private Long userId; + @Schema(description = "사용자 이메일", example = "user@example.com") private String email; + @Schema(description = "사용자 이름", example = "박성준") private String username; + @Schema(description = "결과 메시지", example = "회원가입이 완료되었습니다.") private String message; } diff --git a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/KakaoAuthService.java b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/KakaoAuthService.java index 293b70e..9c31097 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/KakaoAuthService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/KakaoAuthService.java @@ -10,13 +10,17 @@ import com.indayvidual.server.domain.user.repository.UserProviderRepository; import com.indayvidual.server.domain.user.repository.UserRepository; import com.indayvidual.server.domain.user.service.UserService.RefreshTokenService; +import com.indayvidual.server.global.api.code.status.ErrorStatus; import com.indayvidual.server.global.client.KakaoApiClient; import com.indayvidual.server.global.config.security.JwtTokenProvider; +import com.indayvidual.server.global.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Slf4j @Service @@ -31,8 +35,15 @@ public class KakaoAuthService { @Transactional public LoginResponseDTO loginWithKakao(String kakaoAccessToken) { - // 1. 카카오 access token 검증 + 프로필 조회 - KakaoProfile profile = kakaoApiClient.fetchProfile(kakaoAccessToken); + KakaoProfile profile; + try { + // 1. 카카오 access token 검증 + 프로필 조회 + profile = kakaoApiClient.fetchProfile(kakaoAccessToken); + } catch (GeneralException e) { + throw e; // 이미 매핑된 ErrorStatus + } catch (Exception e) { + throw new GeneralException(ErrorStatus.AUTH_OAUTH_PROVIDER_ERROR); + } // 2. 우리 서비스의 User 조회 or 신규 생성 User user = userProviderRepository @@ -56,9 +67,17 @@ public LoginResponseDTO loginWithKakao(String kakaoAccessToken) { } private User signUpKakaoUser(KakaoProfile profile) { - String email = profile.getKakao_account().getEmail(); - String nickname = profile.getKakao_account().getProfile().getNickname(); - String image = profile.getKakao_account().getProfile().getProfile_image_url(); + String email = Optional.ofNullable(profile.getKakao_account()) + .map(KakaoProfile.KakaoAccount::getEmail) + .orElse(null); // 이메일 동의 X 케이스 고려 + String nickname = Optional.ofNullable(profile.getKakao_account()) + .map(KakaoProfile.KakaoAccount::getProfile) + .map(KakaoProfile.KakaoAccount.Profile::getNickname) + .orElse("카카오 사용자"); + String image = Optional.ofNullable(profile.getKakao_account()) + .map(KakaoProfile.KakaoAccount::getProfile) + .map(KakaoProfile.KakaoAccount.Profile::getProfile_image_url) + .orElse(null); User newUser = User.builder() .email(email) diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/RefreshTokenService.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/RefreshTokenService.java index bbd686a..d12445b 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/RefreshTokenService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/RefreshTokenService.java @@ -5,7 +5,9 @@ import com.indayvidual.server.domain.user.entity.User; import com.indayvidual.server.domain.user.repository.RefreshTokenRepository; import com.indayvidual.server.domain.user.repository.UserRepository; +import com.indayvidual.server.global.api.code.status.ErrorStatus; import com.indayvidual.server.global.config.security.JwtTokenProvider; +import com.indayvidual.server.global.exception.GeneralException; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,15 +44,20 @@ public String saveRefreshToken(User user) { /** /api/auth/refresh 호출 */ public LoginResponseDTO rotate(String oldRefresh) { - Claims claims = jwt.parseClaims(oldRefresh); - log.info("claims.getId() = {}", claims.getId()); - log.info("claims = {}", claims); // 전체 claims 로그 - log.info("추출된 tokenId(jti): {}", claims.getId()); + Claims claims; + try { + claims = jwt.parseClaims(oldRefresh); + } catch (io.jsonwebtoken.ExpiredJwtException e) { + throw new GeneralException(ErrorStatus.AUTH_REFRESH_EXPIRED); + } catch (io.jsonwebtoken.JwtException e) { + throw new GeneralException(ErrorStatus.AUTH_REFRESH_INVALID); + } + RefreshToken stored = repo.findByTokenId(claims.getId()) - .orElseThrow(() -> new IllegalStateException("유효하지 않은 refreshToken")); + .orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_REFRESH_NOT_FOUND)); if (!stored.match(oldRefresh) || stored.isRevoked() || stored.getExpiredAt().isBefore(LocalDateTime.now())) { - throw new IllegalStateException("만료되었거나 취소된 refreshToken"); + throw new GeneralException(ErrorStatus.AUTH_REFRESH_REVOKED); } // 1) 현재 refreshToken 사용 종료 @@ -58,7 +65,7 @@ public LoginResponseDTO rotate(String oldRefresh) { // 2) 새 토큰 발급 User user = userRepo.findById(Long.parseLong(claims.getSubject())) - .orElseThrow(() -> new IllegalStateException("존재하지 않는 유저")); + .orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_USER_NOT_FOUND)); String newAccess = jwt.generateAccessToken(user); String newRefresh = saveRefreshToken(user); // 위 메서드 재사용 diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java index 25b50c3..73af704 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserAuthServiceImpl.java @@ -12,19 +12,18 @@ import com.indayvidual.server.domain.user.repository.RefreshTokenRepository; import com.indayvidual.server.domain.user.repository.UserProviderRepository; import com.indayvidual.server.domain.user.repository.UserRepository; +import com.indayvidual.server.global.api.code.status.ErrorStatus; import com.indayvidual.server.global.config.security.JwtTokenProvider; -import com.indayvidual.server.global.config.security.JwtValidationType; -import com.indayvidual.server.global.config.security.UserAuthentication; +import com.indayvidual.server.global.exception.GeneralException; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; -import java.util.List; + @Service @RequiredArgsConstructor @@ -41,7 +40,7 @@ public class UserAuthServiceImpl implements UserAuthService { public SignupResponseDTO signupWithEmail(SignupRequestDTO request) { if (userRepository.existsByEmail(request.getEmail())) { - throw new IllegalArgumentException("이미 존재하는 이메일입니다."); + throw new GeneralException(ErrorStatus.AUTH_EMAIL_DUPLICATED); } String encodedPassword = passwordEncoder.encode(request.getPassword()); @@ -74,9 +73,10 @@ public SignupResponseDTO signupWithEmail(SignupRequestDTO request) { } @Override + @Transactional public LoginResponseDTO loginWithEmailAndPassword(String email, String rawPassword) { User user = userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_INVALID_CREDENTIALS)); boolean hasLocalProvider = userProviderRepository .findByUserAndProvider(user, Provider.LOCAL) @@ -84,17 +84,12 @@ public LoginResponseDTO loginWithEmailAndPassword(String email, String rawPasswo .isPresent(); // UserProvider에서 LOCAL provider 확인 - if (!hasLocalProvider) { - throw new IllegalArgumentException("소셜 로그인 유저입니다. 이메일 로그인 불가"); - } - - // 비밀번호가 null인 경우 (소셜 로그인만 있는 경우) - if (user.getPassword() == null) { - throw new IllegalArgumentException("소셜 로그인 유저입니다. 이메일 로그인 불가"); + if (!hasLocalProvider || user.getPassword() == null) { + throw new GeneralException(ErrorStatus.AUTH_LOCAL_NOT_AVAILABLE); } if (!passwordEncoder.matches(rawPassword, user.getPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + throw new GeneralException(ErrorStatus.AUTH_INVALID_CREDENTIALS); } return createLoginResponse(user); diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index 4f1410d..26edf67 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -41,6 +41,19 @@ public enum ErrorStatus implements BaseErrorCode { USER_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "USER4013", "토큰이 만료되었습니다."), USER_INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "USER4014", "아이디 또는 비밀번호가 올바르지 않습니다."), + // ===== AUTH ===== + AUTH_EMAIL_DUPLICATED(HttpStatus.CONFLICT, "AUTH4091", "이미 가입된 이메일입니다."), + AUTH_INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH4010", "이메일 또는 비밀번호가 올바르지 않습니다."), + AUTH_LOCAL_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "AUTH4001", "소셜 전용 계정으로 이메일 로그인이 불가합니다."), + AUTH_REFRESH_MISSING(HttpStatus.BAD_REQUEST, "AUTH4002", "Refresh-Token 헤더가 없습니다."), + AUTH_REFRESH_INVALID(HttpStatus.UNAUTHORIZED, "AUTH4011", "유효하지 않은 리프레시 토큰입니다."), + AUTH_REFRESH_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH4012", "만료된 리프레시 토큰입니다."), + AUTH_REFRESH_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH4013", "저장된 리프레시 토큰이 없습니다."), + AUTH_REFRESH_REVOKED(HttpStatus.UNAUTHORIZED, "AUTH4014", "이미 사용되었거나 취소된 리프레시 토큰입니다."), + AUTH_USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH4015", "존재하지 않는 사용자입니다."), + AUTH_OAUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH4016", "소셜 액세스 토큰이 유효하지 않습니다."), + AUTH_OAUTH_PROVIDER_ERROR(HttpStatus.BAD_GATEWAY, "AUTH5021", "소셜 인증 제공자와의 통신에 실패했습니다."), + // 계정 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."), USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER4091", "이미 존재하는 사용자입니다."), diff --git a/src/main/java/com/indayvidual/server/global/client/KakaoApiClient.java b/src/main/java/com/indayvidual/server/global/client/KakaoApiClient.java index fefe44f..2cc73e4 100644 --- a/src/main/java/com/indayvidual/server/global/client/KakaoApiClient.java +++ b/src/main/java/com/indayvidual/server/global/client/KakaoApiClient.java @@ -2,13 +2,16 @@ import com.indayvidual.server.domain.user.dto.external.KakaoProfile; import com.indayvidual.server.domain.user.dto.external.TokenInfo; +import com.indayvidual.server.global.api.code.status.ErrorStatus; +import com.indayvidual.server.global.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.http.HttpStatusCode; +import reactor.core.publisher.Mono; @Slf4j @Component @@ -21,32 +24,42 @@ public class KakaoApiClient { @Value("${kakao.oauth.user-info-uri}") private String userInfoUri; - private final WebClient webClient = WebClient.builder() - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); + // 주입형 WebClient (타임아웃/풀 설정은 별도 Config에서) + private final WebClient kakaoWebClient; - /** 토큰 검증 + 프로필 반환 */ public KakaoProfile fetchProfile(String accessToken) { - - // 1) 토큰 유효성 검사 - TokenInfo tokenInfo = webClient.get() + // 1) 토큰 유효성 검사 (선택사항 - 완전히 제거해도 됨) + TokenInfo tokenInfo = kakaoWebClient.get() .uri(tokenInfoUri) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, resp -> + Mono.error(new GeneralException(ErrorStatus.AUTH_OAUTH_INVALID_TOKEN))) + .onStatus(HttpStatusCode::is5xxServerError, resp -> + Mono.error(new GeneralException(ErrorStatus.AUTH_OAUTH_PROVIDER_ERROR))) .bodyToMono(TokenInfo.class) .block(); - // (옵션) app_id 일치 여부, 만료까지 남은 시간 확인 가능 - log.debug("kakao token expires_in={}", tokenInfo.getExpiresIn()); + // 만료 시간만 확인 (선택사항) + if (tokenInfo != null && tokenInfo.getExpiresIn() != null) { + log.debug("kakao token expires_in={}", tokenInfo.getExpiresIn()); + } - // 2) 프로필 조회 - KakaoProfile profile = webClient.get() + // 2) 프로필 조회 (핵심) + KakaoProfile profile = kakaoWebClient.get() .uri(userInfoUri) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, resp -> + Mono.error(new GeneralException(ErrorStatus.AUTH_OAUTH_INVALID_TOKEN))) + .onStatus(HttpStatusCode::is5xxServerError, resp -> + Mono.error(new GeneralException(ErrorStatus.AUTH_OAUTH_PROVIDER_ERROR))) .bodyToMono(KakaoProfile.class) .block(); + if (profile == null || profile.getId() == null) { + throw new GeneralException(ErrorStatus.AUTH_OAUTH_PROVIDER_ERROR); + } return profile; } } diff --git a/src/main/java/com/indayvidual/server/global/config/KakaoClientConfig.java b/src/main/java/com/indayvidual/server/global/config/KakaoClientConfig.java new file mode 100644 index 0000000..0734c85 --- /dev/null +++ b/src/main/java/com/indayvidual/server/global/config/KakaoClientConfig.java @@ -0,0 +1,31 @@ +package com.indayvidual.server.global.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +@Configuration +public class KakaoClientConfig { + + @Bean + public WebClient kakaoWebClient(WebClient.Builder builder) { + HttpClient http = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000) + .responseTimeout(Duration.ofSeconds(3)) + .doOnConnected(conn -> conn + .addHandlerLast(new ReadTimeoutHandler(3, TimeUnit.SECONDS)) + .addHandlerLast(new WriteTimeoutHandler(3, TimeUnit.SECONDS))); + + return builder + .clientConnector(new ReactorClientHttpConnector(http)) + .build(); + } +} \ No newline at end of file