이 포스팅에서는 spring security 및 jwt, Spring Data JPA 관련 내용은 다루고 있지 않습니다.
RestTemplate vs FeignClient?
구글 서버, 카카오 서버와 HTTP 통신을 하기 위해선 라이브러리가 필요합니다.
처음에는 restTemplate을 사용하여 구글로그인을 구현했습니다.
하지만 카카오 로그인을 구현하는 와중에 FeignClient라는 것을 알게 되어
둘 중 무엇으로 통일할지 고민하며 특징을 비교해 보았습니다.
RestTemplate
REST API를 호출할 수 있는 Spring 내장 클래스입니다.
Spring3.0부터 지원되었고, json, xml 응답을 모두 받을 수 있습니다.
REST API 서비스를 요청 후 응답으로 받을 수 있도록 설계되어 있으며
HTTP 프로토콜의 메서드(ex.GET)들에 적합한 여러 메서드들을 제공합니다.
Spring Framework 5부터는 WebFluxa 스택과 함께 WebClient라는 새로운 HTTP 클라이언트를 도입하여
기존의 동기식 API를 제공할 뿐만 아니라 효율적인 비차단 및 비동기 접근 방식을 지원합니다.
멀티 스레드 방식을 사용
Blocking I/O 기반의 동기방식을 사용하는 템플릿
FeignClient
Feign은 Nexflix에서 개발된 Http client binder이다.
Feign을 사용하면 웹 서비스 클라이언트를 보다 쉽게 작성할 수 있습니다.
Feign을 사용하기 위해서는 interface를 작성하고 annotation을 선언하기만 하면 됩니다.
ex) Spring Dat JPA에서 실제 쿼리를 작성하지 않고 interface 만 지정하여 쿼리 실행 구현체를 자동으로 만들어주는 것과 비슷
FeignClient를 선택한 이유
1. 책임의 분리
예를 들어 authService계층의 관심사는 로그인이나 회원가입을 하고, 토큰 리프레시를 하는 것입니다.
restTemplate을 사용하면 카카오 서버(구글 서버)와 http 통신을 하여 유저의 정보를 조회해 오는 책임 역시 authService가 가집니다.
하지만 feignclient를 사용하면 카카오 서버(구글 서버)와 통신하여 유저 정보를 가져오는 책임을 분리하여
authService에서는 이를 가져다 쓰며 반환에 대한 결과만을 책임으로 갖고 있습니다.
2. 테스트 용이성
feign은 spring Cloud에서 @MockFeignClient와 같은 어노테이션을 사용하여 쉽게 Mock 객체를 생성할 수 있습니다.
이를 통해 테스트 시 실제 HTTP 통신을 하지 않고 Mock 객체를 주입하여 독립적으로 테스트할 수 있습니다.
또한 Feign 인터페이스를 직접 호출하여 테스트를 수행할 수 있어 명시적이고 간결하게 작서 가능합니다.
추가적으로 코드 양이나 직관성 역시 feign이 더 좋다고 생각하여 선택하게 되었습니다.
FeignClient 연동
의존성 추가
// build.gradle
implementation platform("org.springframework.cloud:spring-cloud-dependencies:2022.0.3")
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
feignClient를 사용하기 위한 의존성을 추가해 줍니다.
feignClient를 사용하기 위해 필요한 의존성으로는 spring-cloud-starter-openfeign입니다.
spring-cloud 의존성을 추가할 때에는 springCloudVersion과 SpringBoot 버전이 충돌이 나지 않도록 주의해야 합니다.
해당 사이트에서 자신의 환경에 맞는 spring-cloud-dependencies를 함께 추가해 줍니다.
썸네일의 스프링 부트 버전은 3.1.x 이기 때문에 2022.0.x의 가장 최신 버전인 2022.0.3을 추가해 주었습니다.
https://spring.io/projects/spring-cloud/
@EnableFeignClients 추가
@EnableJpaAuditing
@SpringBootApplication
@EnableFeignClients
public class SumnailApplication {
public static void main(String[] args) {
SpringApplication.run(SumnailApplication.class, args);
}
}
@EnableFeignClients 어노테이션은 Feign 클라이언트를 자동 스캔하고, @FeignClient를 추가한 클라이언트를 스프링 컨테이너에 빈으로 등록합니다.
AuthController
//domain.auth.controller/dto/request/AuthLoginRequest.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AuthLoginRequest {
private String provider; // kakao or google
private String token;
}
// domain/auth/controller/AuthController.java
@RestController
@RequestMapping("v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 소셜 로그인 ( 구글, 카카오 )
*/
@PostMapping("signin")
public ResponseEntity<AuthTokenResponse> login(@RequestBody AuthLoginRequest request) {
AuthTokenResponse response = authService.signIn(request.getProvider(), request.getToken());
return ResponseEntity.status(HttpStatus.OK).body(response);
}
...
}
authController에 login함수를 만들어 provider과 idToken을 request body로 받아오는 코드를 작성해 줍니다.
썸네일에선 카카오와 구글 모두 클라이언트가 idToken을 보내주기로 합의가 된 상태입니다
FeignClient로 서버와 HTTP 통신
@FeignClient(value= 'FeignClient 이름', url = '호출할 대상의 기본 url', configuration = '추가적인 구성 클래스')
@FeignClient 어노테이션은 Feign 클라이언트를 정의하는 데 사용됩니다.
value 속성은 Feign 클라이언트의 이름을 지정하며, 이 이름은 나중에 해당 클라이언트를 참조하는 데 사용됩니다.
url 속성은 요청이 보내질 대상의 기본 url을 지정합니다.
configuration 속성은 추가적인 구성 클래스를 지정할 수 있습니다.
구글에서 유저 정보 가져오기
1. Google developers 설정
https://notspoon.tistory.com/45
해당 게시물에 따라 프로젝트를 생성해 줍니다.
2. GoogleClient 생성
// domain/auth/service/helper/GoogleClient.java
@FeignClient(value = "googleClient", url = "https://oauth2.googleapis.com", configuration = FeignClientConfig.class)
public interface GoogleClient {
@GetMapping("/tokeninfo")
AuthGoogleLoginDto getUserInfo(@RequestParam("id_token") String idToken);
}
Spring Cloud의 Feign을 사용하여 Google OAuth2.0 서비스에 대한 클라이언트를 정의하는 JAVA 인터페이스입니다.
해당 인터페이스는 Google OAuth2.0 서비스의 /tokeninfo 엔드포인트에 대한 GET 요청을 보냅니다.
@RequestParam 어노테이션은 url 쿼리 매개변수로 전달되는 idToken 값을 받고, idToken에서 사용자 정보를 가져옵니다.
3. Response Dto 생성
// domain/auth/controller/dto/AuthGoogleLoginDto.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AuthGoogleLoginDto {
private String name;
private String email;
private String picture;
}
아래 공식문서에 받을 수 있는 필드가 있으니 필요한 필드로 구성된 dto를 만들어 반환타입으로 지정해 주면 됩니다.
썸네일에서는 로그인과 회원가입에 name, email, picture 세 가지만 필요하여 AuthGoogleLoginDto를 만들어 반환해 주었습니다.
https://developers.google.com/identity/openid-connect/openid-connect?hl=ko#an-id-tokens-payload
카카오에서 유저 정보 가져오기
1. Kakao Developers 설정
https://notspoon.tistory.com/34
해당 게시글을 따라 kakao developers 설정을 해줍니다.
썸네일은 이메일이 꼭 필요하여 비즈앱으로 전환하여 이메일 필수동의로 설정해 주었습니다.
2. KakaoClient 생성
// domain/auth/service/helper/kakaoClient.java
@FeignClient(value = "kakaoClient", url = "https://kapi.kakao.com", configuration = FeignClientConfig.class)
public interface KakaoClient {
@GetMapping("/v2/user/me")
AuthKakaoLoginDto getUserInfo(@RequestHeader("Authorization") String accessToken);
}
Spring Cloud의 Feign을 사용하여 카카오 OAuth2.0 서비스에 대한 클라이언트를 정의하는 JAVA 인터페이스입니다.
해당 인터페이스는 카카오 OAuth2.0 서비스의 /v2/user/me 엔드포인트에 대한 GET 요청을 보냅니다.
@RequestHeader 어노테이션은 HTTP 요청 헤더로 전달되는 Authorization 값을 받습니다.
이 값은 카카오 Oauth2.0 서비스에서 사용되는 액세스 토큰이며, 이를 통해 사용자 정보를 가져옵니다.
3. Response Dto 생성
// domain/auth/controller/dto/AuthKakaoLoginDto.java
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AuthKakaoLoginDto {
@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public static class KakaoAccount {
private Profile profile;
private String email;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public static class Profile {
private String nickname;
@JsonProperty("thumbnail_image_url")
private String thumbnailImageUrl;
}
}
}
구글 로그인과 마찬가지로 아래 공식문서에 받을 수 있는 사용자 정보 필드가 있으니
필요한 필드들로 구성된 dto를 만들어 반환타입으로 지정해 주면 됩니다.
썸네일에선 email, nickname, thumbnailImageUrl 세 가지만 필요하여 AuthKakaoLoginDto를 만들어 반환해 주었습니다.
썸네일에선 camelCase를 사용하고 있어 @JsonProperty를 사용해 JSON 데이터를 JAVA 객체로 매핑해 주었습니다.
response dto 필드여도 동의항목에서 선택동의나 필수동의를 하지 않았다면 아무 값도 반환되지 않음 ❗️
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
configuration
configuration 속성을 사용하는 것은 feignClient에 특별한 구성을 지정하기 위한 것입니다.
주로 에러 처리 및 인터셉터 설정, 기타 특수한 동작을 정의하는 데 사용됩니다.
@Configuration
public class FeignClientConfig {
@Bean
public ErrorDecoder errorDecoder() {
return new CustomErrorDecoder();
}
public static class CustomErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
if (response.status() == 401) {
// Unauthorized (401) 에러 처리
return new CustomException(ErrorCode.UNAUTHORIZED_TOKEN);
} else if (response.status() == 404) {
// Not Found (404) 에러 처리
return new CustomException(ErrorCode.NOT_FOUND);
}
// 기본적으로는 FeignException을 던집니다.
return FeignException.errorStatus(methodKey, response);
}
}
}
CustomErrorDecoder 클래스는 ErrorDecoder 인터페이스의 decode 메서드를 구현하여
Feign 클라이언트에서 발생한 에러를 처리하는 사용자 지정 로직을 정의합니다.
401,404 status code에 대해서는 직접 커스텀한 CustomException을 던지고,
그 외의 경우에는 기본적으로 FeignException을 던집니다.
AuthService
로그인 및 회원가입 비즈니스 로직
// domain/auth/service/AuthService.java
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final GoogleClient googleClient;
private final KakaoClient kakaoClient;
private final UserRepository userRepository;
private final RefreshTokenService refreshTokenService;
public AuthTokenResponse signIn(String provider, String token) {
User user = signInByProvider(provider, token);
User findUser = userRepository.findByEmail(user.getEmail()).
orElse(null);
if (findUser == null) { // 최초 로그인이라면 회원가입 시키기
userRepository.save(user);
}
return createAndSaveToken(user);
}
private User signInByProvider(String provider, String token) {
if (!provider.equals(Provider.GOOGLE.getProviderName()) && !provider.equals(Provider.KAKAO.getProviderName())) {
throw new CustomException(ErrorCode.INVALID_PROVIDER_NAME);
}
// 구글 로그인
if (Provider.GOOGLE.getProviderName().equals(provider)) {
return User.createUserByGoogleLogin(googleClient.getUserInfo(token));
}
// 카카오 로그인
AuthKakaoLoginDto userInfo = kakaoClient.getUserInfo("Bearer " + token);
return User.createUserByKakaoLogin(userInfo);
}
private AuthTokenResponse createAndSaveToken(User user) {
String accessToken = jwtTokenProvider.generateAccessToken(user);
String refreshToken = jwtTokenProvider.generateRefreshToken(user);
refreshTokenService.saveRefreshToken(refreshToken, user.getId());
return AuthTokenResponse.of(accessToken, refreshToken);
}
}
signInByProvider 메서드에서 provider가 google일 때 googleClient의 getUserInfo로 idToken을 전송하여 유저 정보를 받아와 그것으로 User를 생성해 반환합니다
마찬가지로 provider가 kakao일 때 kakaoClient의 getUserInfo로 accessToken을 전송하여 유저 정보를 받아와 그것으로 User를 생성해 반환합니다. 이때 토큰 앞에 Bearer을 붙여야 제대로 된 요청이 됩니다 ❗️
signIn 함수에서 생성된 user의 이메일로 userRepository에서 검색하여
없는 user, 즉 최초로 로그인한 user라면 회원가입(유저 db에 저장)을 시킨 후 로그인(토큰 발급) 시키고,
존재하는 user라면 바로 로그인(토큰 발급) 시킵니다.
썸네일에서는 데이터베이스에 refreshToken을 저장하는데
createAndSaveToken 함수에서는 해당 user의 refreshToken이 db에 존재하지 않는 경우 저장하고,
이미 저장되어 있는 경우 업데이트해줍니다.
관련 코드들
enum 적용
// domain/auth/entity/Provider.java
@Getter
@RequiredArgsConstructor
public enum Provider {
KAKAO("kakao"),
GOOGLE("google");
private final String providerName;
}
카카오와 구글 외에 다른 소셜 로그인으로도 확장될 가능성을 생각해
Enum을 사용하여 Provider의 값을 더 유지보수 하기 쉽게 만들어주었습니다
정적팩토리 메서드 적용
// domain/user/entity/User.java
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User {
@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String profileImage;
@Builder
public User(String name, String email, String profileImage) {
this.name = name;
this.email = email;
this.profileImage = profileImage;
}
public static User createUserByGoogleLogin(AuthGoogleLoginDto dto) {
return User.builder()
.name(dto.getName())
.email(dto.getEmail())
.profileImage(dto.getPicture())
.build();
}
public static User createUserByKakaoLogin(AuthKakaoLoginDto dto) {
return User.builder()
.name(dto.getKakaoAccount().getProfile().getNickname())
.email(dto.getKakaoAccount().getEmail())
.profileImage(dto.getKakaoAccount().getProfile().getThumbnailImageUrl())
.build();
}
createUserByGoogleLogin과 createByKakaoLogin에서 정적팩토리 메서드 및 빌더 패턴을 사용하였습니다.
빌더 패턴을 사용하여 객체 생성 시 필요한 매개변수를 체인 형태로 지정하여 가독성을 높이고
정적 팩토리 메서드를 사용하여 일반 생성자랑 다르게 이름을 부여하여 각각 카카오 로그인과 구글 로그인을 기반으로 사용자를 생성한다는 것을 명시적으로 나타냅니다.
또한 객체 생성 로직이 도메인 내부에 집중되어 있어, 객체 생성에 대한 변경이 필요한 경우 유지보수를 쉽게 만들어줍니다.
// domain/auth/controller/dto/response/AuthTokenResponse.java
@Getter
@Builder
public class AuthTokenResponse {
private String accessToken;
private String refreshToken;
public static AuthTokenResponse of(String accessToken, String refreshToken) {
return AuthTokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
마찬가지로 빌더 패턴과 정적 팩토리 메서드 패턴을 활용하였습니다
토큰 저장 또는 업데이트
// domain/refresh_token/service/RefreshTokenService.java
@Service
@RequiredArgsConstructor
@Transactional
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public void saveRefreshToken(String refreshToken, Long keyUserId) {
RefreshToken newRefreshToken = RefreshToken.createRefreshToken(refreshToken, keyUserId);
// 기존 토큰이 있으면 업데이트하고, 없으면 새로 생성하여 저장
refreshTokenRepository.findByKeyUserId(keyUserId)
.ifPresentOrElse(
(findRefreshToken) -> findRefreshToken.updateToken(refreshToken),
() -> refreshTokenRepository.save(newRefreshToken)
);
}
...
}
관련 PR
Sum:nail 깃허브 : https://github.com/Sum-nail/sum-nail-server/pull/45/files