build.gradle에 종속성 설정
먼저, 우리는 OpenFeign을 쓸 것이므로, 다음과 같이 build.gradle에 종속성을 설정해준다.
ext {
set('springCloudVersion', "2023.0.0")
}
dependencies {
//FeignClient
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
여기서 중요한 점은 이 버전의 Spring Cloud(OpenFeign)을 쓰려면 Spring Boot가 3.2.3 버전 이상이어야 한다. 버전이 호환되지 않으면 빌드 에러가 난다. OpenFeign에 맞추어 Spring Boot 버전을 변경하거나, Spring Boot 버전에 맞추어 OpenFeign 버전을 변경해야 한다.
아래 링크에서 버전 호환성을 확인할 수 있다.
application.yml 구성
구글 클라우드에서 설정했던 클라이언트 ID 및 시크릿을 여기에 등록해준다. 이 중 클라이언트 시크릿 값은 절대 깃허브에 올라가서는 안된다(application.yml 파일을 .gitignore에 등록하는 것 추천).
# OAuth2
oauth2:
kakao:
client-id: [REST API 키]
client-secret: [설정한 클라이언트 시크릿]
redirect-uri: [설정한 URI]
token-uri: https://kauth.kakao.com/oauth/token
resource-uri: https://kapi.kakao.com/v2/user/me
KakaoOAuthApi 인터페이스 설정
구글 OAuth 서버에 Authorization Code를 주고 Access Token을 받아올 API의 인터페이스이다. RestTemplate을 써서 header 값을 하나하나 세팅 한 후 API를 호출하고 JsonNode로 답변을 받아 다시 하나하나 값을 추출하는 방식도 있겠지만, OpenFeign을 통하면 다음과 같이 선언적인 방식으로 외부 API를 간편하게 호출할 수 있다.
@FeignClient(
name = "KakaoOAuth",
url = "https://kauth.kakao.com")
public interface KakaoOAuthApi {
@PostMapping(
value = "/oauth/token?" +
"code={CODE}" +
"&client_id={CLIENT_ID}" +
"&client_secret={CLIENT_SECRET}" +
"&redirect_uri={REDIRECT_URI}" +
"&grant_type={GRANT_TYPE}")
KakaoTokenDto kakaoGetToken(
@PathVariable("CODE") String code,
@PathVariable("CLIENT_ID") String clientId,
@PathVariable("CLIENT_SECRET") String clientSecret,
@PathVariable("REDIRECT_URI") String redirectUri,
@PathVariable("GRANT_TYPE") String grantType);
}
위의 API는 엑세스 토큰을 받아와야 하므로, KakaoTokenDto를 만들어 받아와야 하는 값을 명시한다.
@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class KakaoTokenDto {
@NotNull(message = "accessToken may not be null")
@JsonProperty("access_token")
private String accessToken;
}
참고로 카카오 OAuth 서버가 반환해주는 값은 다음과 같다. 우리는 이 중 access_token을 담는 dto를 만들어주었다.
KakaoResourceApi 인터페이스 설정
이 인터페이스는 Access Token을 기반으로 Google 리소스 서버에서 유저 정보를 가져올 API이다. 반드시 Authorization Header에 엑세스 토큰을 담아야 하므로, @RequestHeader를 사용해 엑세스 토큰을 담아준다.
@FeignClient(
name = "KakaoResource",
url = "https://kapi.kakao.com")
public interface KakaoResourceApi {
@GetMapping(
value = "/v2/user/me")
KakaoResourceDto kakaoGetResource(@RequestHeader("Authorization") String accessToken);
}
위의 API는 유저정보를 받아오므로, KakaoResourceDto를 만들어 받아와야 하는 값을 명시한다. 나는 id, email, picture, name 이 4가지 값을 받아오기로 했다.
@Getter
@EqualsAndHashCode
@NoArgsConstructor
public class KakaoResourceDto {
@JsonProperty("id")
private String id;
@JsonProperty("email")
private String email;
@JsonProperty("profile_image_url")
private String picture;
@JsonProperty("nickname")
private String nickname;
@Builder
public KakaoResourceDto(String id, String email, String picture, String nickname){
this.id = id;
this.email = email;
this.picture = picture;
this.nickname = nickname;
}
}
여기까지 했으면 이제 OpenFeign을 통해서 소셜 로그인을 하는 준비가 끝났다. 이제 컨트롤러와 서비스를 만들어서 사용해보자.
OAuth2Controller.java
테스트 용으로 다음과 같이 컨트롤러를 만든다. 이 컨트롤러의 리소스 경로는 구글 클라우드에서 등록해줬던 리다이렉트 URI와 일치해야 한다. 이 컨트롤러는 Google OAuth 서버에서 Authorization Code를 받아오는 역할을 한다.
@RestController
@RequiredArgsConstructor
public class OAuth2Controller {
private final OAuth2Service oAuth2Service;
@GetMapping("/dev/login/oauth/{registration}")
public CommonResponse<?> devSocialLogin(@RequestParam String code, @PathVariable String registration) {
String loginMessage = oAuth2Service.devSocialLogin(code, registration);
return CommonResponse.postResponse(HttpStatus.OK.value(), loginMessage);
}
}
OAuth2Service.java
서비스는 다음과 같은 로직을 따른다.
- client_id, client_secret 등을 사용해 oauth 서버에서 access_token 발급(getAccessToken 메소드)
- access_token를 통해서 리소스 서버에서 user의 리소스(개인정보) 요청(getUserResource 메소드)
- 이미 DB에 존재하는 user인지 확인(checkSignUp 메소드)
- 존재하는 유저가 아니라면 email과 id를 각각 아이디와 패스워드로 DB에 저장한 후 jwt 토큰 반환(socialSignUp 메소드)
- 존재하는 유저가 맞다면 jwt 토큰만 반환
다만 여기서는 개발 테스트용이므로, devSocialLogin에서 리턴하는 값을 그냥 String으로 잡아주었다.
@Service
@RequiredArgsConstructor
@Transactional
public class OAuth2Service {
private final Environment env;
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberService memberService;
private final JwtTokenProvider jwtTokenProvider;
private final GoogleOAuthApi googleOAuthApi;
private final GoogleResourceApi googleResourceApi;
public String devSocialLogin(String code, String registration) {
String accessToken = getAccessToken(code, registration);
GoogleResourceDto googleResourceDto = getUserResource(accessToken, registration);
String id = googleResourceDto.getId();
String email = googleResourceDto.getEmail();
String picture = googleResourceDto.getPicture();
String nickname = googleResourceDto.getNickname();
if (checkSignUp(email)){
return "로그인에 성공했습니다, "+jwtTokenProvider.generateJwtToken(email).getJwtAccessToken();
} else {
socialSignUp(email, id, picture, nickname);
return "회원가입 및 로그인에 성공했습니다, "+jwtTokenProvider.generateJwtToken(email).getJwtAccessToken();
}
}
public String socialSignUp(String email, String password, String picture, String nickname) {
/* 프로필 및 맴버 저장 */
try {
memberService.createMember(email, password, picture, nickname);
return "회원가입이 완료되었습니다.";
} catch (DataIntegrityViolationException e) {
/* 중복된 이메일 값이 삽입되려고 할 때 발생하는 예외 처리,unique = true 때문에 발생하는 에러 */
return "이미 사용 중인 이메일 주소입니다.";
}
}
public Boolean checkSignUp(String email){
Member member = memberRepository.findMemberByEmail(email);
return member != null;
}
/* oauth 서버에서 access_token 받아옴 */
private String getAccessToken(String authorizationCode, String registration) {
String clientId = env.getProperty("oauth2." + registration + ".client-id");
String clientSecret = env.getProperty("oauth2." + registration + ".client-secret");
String redirectUri = env.getProperty("oauth2." + registration + ".redirect-uri");
String tokenUri = env.getProperty("oauth2." + registration + ".token-uri");
GoogleTokenDto tokenDto = googleOAuthApi.googleGetToken(authorizationCode, clientId, clientSecret, redirectUri, "authorization_code");
return tokenDto.getAccessToken();
}
/* 리다이렉트 URL을 통해서 리소스 가져옴 */
private GoogleResourceDto getUserResource(String accessToken, String registration) {
String resourceUri = env.getProperty("oauth2." + registration + ".resource-uri");
return googleResourceApi.googleGetResource("Bearer " + accessToken);
}
}
이제 다시 브라우저로 접근해 카카오 로그인을 진행해보자.
처음 로그인할 때는 다음과 같이 회원가입했다는 메세지가, 두 번째부터는 로그인에 성공했다는 메세지가 뜬다.
트러블 슈팅: @FeignClient 빈 등록 불가
"The bean 'kakaoOAuth.FeignClientSpecification' could not be registered. Bean already defined and overriding is disabled."와 같은 메세지가 떴다.
그 이유는 2개의 API에서 @FeignClient
의 name
값을 같게 해줬기 때문이었다. @FeignClient
의 name
값으로 스프링 빈이 구분되는 구조이기 때문에, 반드시 모든 API 인터페이스마다 구분되는 값을 넣어주어야 한다.