프로젝트를 진행하며 SpringSecurity없이 Oauth2 소셜 로그인을 구현했다.
기존의 코드를 확장에 유리한 구조로 리팩토링한 과정을 설명하고자 한다.
Oauth2의 동작 흐름에 대해서는 아래 포스팅을 참고하자.
프론트엔드 코드(Thymeleaf)부터 백엔드 코드까지 모두 구현해보았다.
아래는 내가 구현한 코드의 전체 흐름도이다.
🧨 기존 코드의 문제점 - 코드의 중복
기존 코드의 소스코드는 이 곳을 참고하면 된다.
기존 코드는 소셜 타입별로 GoogleOauth2Client / KakaoOauth2Client 를 사용해 소셜 서버에 AccessToken 및 UserInfo 요청을 하는 구조였다.
하지만 이 때, 아래 사진처럼 요청 url과 환경 변수 값, Response 구조만 다를 뿐 전체적인 구조는 거의 동일했다.
코드의 중복이 발생한 것이다.
새로운 소셜 타입이 추가되면, 비슷한 코드를 한 번 더 작성해야한다.
클래스 개수도 소셜타입 하나당 3개가 늘어난다. (xxxOauth2Client, xxxOauth2TokenClient, xxxOauth2UserInfoClient)
이러한 코드의 중복을 해결해보자!
🍃 Oauth2 정보 구조화 (application.yml)
application.yml에 다음과 같은 구조로 Oauth2 정보를 등록해주었다.
client-id, client-secret, redirect-uri, scope 등 클라이언트의 정보는 environment에
auth-uri, token-uri, user-info-uri 등 요청 uri는 provider에 등록했다.
🫘 환경 변수를 Spring 빈 프로퍼티로 주입받기
그리고 이 환경 변수를 받아오기 위해 아래와 같이 Property 클래스를 정의해주었다.
public record Oauth2Property(
SocialType socialType,
EnvironmentProperties environment,
ProviderProperties provider
) {
public record EnvironmentProperties(
String clientId,
String clientSecret,
String redirectUri,
String scope
) {
}
public record ProviderProperties(
String authUri,
String tokenUri,
String userInfoUri
) {
}
}
@ConfigurationProperties 속성을 활용해 프로퍼티 정보들을 Oauth2Property객체로 바인딩해주었다.
해당 클래스 내부에 SocialType별 Oauth2Property를 제공하는 getOauthProperties 메서드도 추가로 정의해주었다.
@ConfigurationProperties(prefix = "oauth2")
public class Oauth2ConfigProperties {
private final Map<SocialType, EnvironmentProperties> environment = new HashMap<>();
private final Map<SocialType, ProviderProperties> provider = new HashMap<>();
public Map<SocialType, Oauth2Property> getOauth2Properties() {
return Arrays.stream(SocialType.values())
.collect(Collectors.toMap(
socialType -> socialType,
socialType -> new Oauth2Property(socialType, environment.get(socialType), provider.get(socialType))
));
}
public Map<SocialType, EnvironmentProperties> getEnvironment() {
return environment;
}
public Map<SocialType, ProviderProperties> getProvider() {
return provider;
}
}
그리고 해당 환경변수들을 Oauth2Client에서 받아 쓰기 위해 설정파일을 정의해줬다.
이 때, Spring에게 우리가 @ConfigurationProperties 어노테이션을 활용함을 알리기 위해 @EnableConfigurationProperties를 붙여줘야한다.
@Configuration
@EnableConfigurationProperties(Oauth2ConfigProperties.class)
public class Oauth2Config {
private final Oauth2ConfigProperties oauth2ConfigProperties;
public Oauth2Config(Oauth2ConfigProperties oauth2ConfigProperties) {
this.oauth2ConfigProperties = oauth2ConfigProperties;
}
@Bean
public Oauth2Client oauth2Client() {
return new Oauth2Client(oauth2ConfigProperties.getOauth2Properties());
}
}
🪽 Oauth2Property를 활용해 요청 보내기
기존에는 아래와 같이 Oauth2Client 구현체 내부에서 아래와 같이 직접 프로퍼티의 값을 가져와야했다.
하지만 위의 과정에서 프로퍼티 정보들을 Oauth2Property 객체에 바인딩했고, 이를 활용하여 요청을 보낼 수 있게 되었다.
(Client 내부에서 프로퍼티 값을 일일이 불러오지 않아도 된다.)
아래는 개선한 Oauth2Client이다.
public class Oauth2Client {
private final Map<SocialType, Oauth2Property> properties;
public Oauth2Client(Map<SocialType, Oauth2Property> properties) {
this.properties = properties;
}
public String getAuthUri(SocialType socialType) {
Oauth2Property property = getProperty(socialType);
return UriComponentsBuilder.fromHttpUrl(property.provider().authUri())
.queryParam("client_id", property.environment().clientId())
.queryParam("redirect_uri", property.environment().redirectUri())
.queryParam("response_type", "code")
.queryParam("scope", property.environment().scope())
.build()
.toUriString();
}
public UserInfo requestUserInfo(SocialType socialType, String code) {
Oauth2Property property = getProperty(socialType);
String accessToken = Oauth2TokenClient.request(property, code);
return Oauth2UserInfoClient.request(property, accessToken);
}
private Oauth2Property getProperty(SocialType socialType) {
return properties.get(socialType);
}
}
이제 실제로 소셜 서버에 요청을 보내는 Oauth2TokenClient, Oauth2UserInfoClient를 개선해보자.
📚 다양한 응답 구조 처리
소셜 타입에 따라서, 요청 구조는 동일해도 응답 구조는 다양하다.
예를 들어, 소셜 타입별 유저 정보 조회 응답 구조는 아래와 같다.
하지만, RestTemplate에서 요청에 대한 응답을 객체로 가져오기 위해선 아래와 같이 클래스 타입을 정의해줘야했다.
응답을 각 소셜의 응답 구조에 맞게 받아오기 위해서 UserInfoResponse라는 인터페이스를 정의해 응답을 추상화했다.
그리고 toUserInfo라는 응답을 도메인으로 바꾸는 메서드도 만들어주었다.
public interface UserInfoResponse {
UserInfo toUserInfo();
}
public record GoogleUserInfoResponse(
String id,
String name,
String picture
) implements UserInfoResponse {
@Override
public UserInfo toUserInfo() {
return new UserInfo(
id,
SocialType.GOOGLE,
name,
picture
);
}
}
public record KakaoUserInfoResponse(
String id,
@JsonProperty("kakao_account") KakaoAccount kakaoAccount
) implements UserInfoResponse {
@Override
public UserInfo toUserInfo() {
Profile profile = kakaoAccount.profile;
return new UserInfo(
id,
SocialType.KAKAO,
profile.nickname,
profile.profileImageUrl
);
}
public record KakaoAccount(
Profile profile
) {
public record Profile(
String nickname,
@JsonProperty("profile_image_url") String profileImageUrl
) {
}
}
}
TokenResponse도 위 방법과 같이 정의해주었다.
public interface TokenResponse {
String toAccessToken();
}
public record GoogleTokenResponse(
@JsonProperty("token_type") String tokenType,
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Integer expiresIn,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("scope") String scope
) implements TokenResponse {
@Override
public String toAccessToken() {
return accessToken;
}
}
public record KakaoTokenResponse(
@JsonProperty("token_type") String tokenType,
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Integer expiresIn,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn
) implements TokenResponse {
@Override
public String toAccessToken() {
return accessToken;
}
}
그리고 SocialType enum 클래스에 타입별 응답 클래스를 지정해주었다.
public enum SocialType {
KAKAO(
KakaoTokenResponse.class,
KakaoUserInfoResponse.class
),
GOOGLE(
GoogleTokenResponse.class,
GoogleUserInfoResponse.class
),
;
private final Class<? extends TokenResponse> tokenResponse;
private final Class<? extends UserInfoResponse> userInfoResponse;
SocialType(Class<? extends TokenResponse> tokenResponse,
Class<? extends UserInfoResponse> userInfoResponse) {
this.tokenResponse = tokenResponse;
this.userInfoResponse = userInfoResponse;
}
public Class<? extends TokenResponse> getTokenResponse() {
return tokenResponse;
}
public Class<? extends UserInfoResponse> getUserInfoResponse() {
return userInfoResponse;
}
}
이를 활용해 TokenClient / UserInfoClient 를 개선한 코드는 아래와 같다.
public class Oauth2TokenClient {
private static final String GRANT_TYPE = "authorization_code";
public static String request(Oauth2Property property, String code) {
HttpHeaders headers = getRequestHeader();
MultiValueMap<String, String> body = getRequestBody(property, code);
TokenResponse response = new RestTemplateBuilder()
.build()
.postForEntity(
property.provider().tokenUri(),
new HttpEntity<>(body, headers),
property.socialType().getTokenResponse()
).getBody();
if (response == null) {
throw new IllegalArgumentException("소셜의 Token 응답값이 잘못되었습니다.");
}
return response.toAccessToken();
}
private static HttpHeaders getRequestHeader() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
return headers;
}
private static MultiValueMap<String, String> getRequestBody(Oauth2Property property, String code) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
EnvironmentProperties environment = property.environment();
formData.add("grant_type", GRANT_TYPE);
formData.add("client_id", environment.clientId());
formData.add("client_secret", environment.clientSecret());
formData.add("redirect_uri", environment.redirectUri());
formData.add("code", code);
return formData;
}
}
public class Oauth2UserInfoClient {
public static UserInfo request(Oauth2Property property, String accessToken) {
HttpHeaders headers = getHttpHeaders(accessToken);
UserInfoResponse response = new RestTemplateBuilder()
.build()
.exchange(
property.provider().userInfoUri(),
HttpMethod.GET,
new HttpEntity<>(headers),
property.socialType().getUserInfoResponse()
)
.getBody();
if (response == null) {
throw new IllegalArgumentException("소셜의 UserInfo 응답값이 잘못되었습니다.");
}
return response.toUserInfo();
}
private static HttpHeaders getHttpHeaders(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
return headers;
}
}
🍒 해당 구조의 장점 느껴보기
해당 구조를 적용함으로써, 코드의 중복을 완전히 줄일 수 있었다.
이제 새로운 소셜 타입을 추가할 때 우리가 해야하는 일은 아래와 같다.
- application.yml에 소셜 프로퍼티 등록하기.
- 해당 소셜의 응답 구조에 맞는 TokenResponse, UserInfoResponse의 구현체 생성하기.
- SocialType enum클래스에 2번에서 생성한 클래스 등록해주기.
이제 응답 객체를 제외한 다른 클래스가 추가하지 않아도 된다!!
🍏 Naver 소셜로그인 추가하기
정말 위의 1-3번 과정만 진행하면된다. 10분만에 추가 가능하다.
public record NaverTokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") String expiresIn
) implements TokenResponse {
@Override
public String toAccessToken() {
return accessToken;
}
}
public record NaverUserInfoResponse(
String resultcode,
String message,
Response response
) implements UserInfoResponse {
@Override
public UserInfo toUserInfo() {
return new UserInfo(
response.id,
SocialType.NAVER,
response.name,
response.profileImage
);
}
private record Response(
String id,
String name,
@JsonProperty("profile_image") String profileImage
) {
}
}
// SocialType.java
NAVER(
NaverTokenResponse.class,
NaverUserInfoResponse.class
),
아래는 Naver 로그인 구현 코드이다.
🕶 최종 완성본 코드
최종 완성본 코드는 아래에서 확인할 수 있다.
'프로그래밍' 카테고리의 다른 글
[Java] I/O Stream (3) | 2023.09.02 |
---|---|
[Spring] @Async와 스레드풀 (4) | 2023.08.25 |
[Docker / MySQL] Docker로 띄운 mySQL 컨테이너에 employee sample DB 적재하기 (2) | 2023.08.15 |
Git-flow란? (1) | 2023.07.12 |
JWT란? (0) | 2023.07.11 |