https://qkrqkrrlrl.tistory.com/160
spring-oauth-client 라이브러리의 동작 흐름 정리
해당 지식은 혼자 디버깅과 gpt, 블로그를 교차 검증 하면서 얻은 정보입니다. 틀린 내용이 있을 수 있습니다. 소셜 로그인을 사용하면서 oauth2를 사용하게 되었는데 코드를 한번 다시 살펴보고
qkrqkrrlrl.tistory.com
여기서 oauth 라이브러리를 추가했을 때 시큐리티 필터의 흐름을 살펴봤었다.
이제는 현재 작성된 스프링 코드를 다시 리팩토링 하면서 확장성이 좋은 코드로 리팩토링을 진행해보려고 한다.
개선하게 됐던 계기는 코드 리뷰를 받으면서 시작됐다.
분명 당시에는 PrincipalOAuth2UserService 코드를 작성하면서 확장성을 챙겼다고 생각했다. 근데 그건 거의 3달 전의 상황이고 지금 다시 한번 코드를 보면서 이해를 바탕으로 수정해보려고 한다.
이전 블로그에서 OAuth2LoginAuthenticationFilter의 attemptAuthentication 메서드에서 인증 처리를 진행한다고 했었다.
말을 안 하고 넘어갔던 부분이 한 가지 있었는데 아래 authenticate에 authenticationRequest를 넘겨주는데 authenticate를 실행하면 ProviderManager의 authenticate 메서드로 들어가게 된다.
ProviderManager
- AuthenticationProvider를 관리한다.
- AuthenticationProvider가 주어진 인증 요청을 처리할 수 있는지 확인한다.
ProviderManager 내부에는 필드로 아래와 같이 AuthenticationProvider를 List로 가지고 있다.
private List<AuthenticationProvider> providers = Collections.emptyList();
providers를 하나씩 순회하면서 지원이 가능한지 파악하게 된다.
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
코드를 보면 prodiver를 하나 꺼내서 try문에서 provider.authenticate를 동작시키는데 지원이 불가능하면 null을 반환하게 된다. null이 아니라면 복사해 놓고 종료시킨다.
이때 OAuth2LoginAuthenticationProvider 구현체가 provider로 동작하게 된다.
아래 흐름을 보면
1. OAuth2LoginAuthenticationFilter
2. ProviderManager
3. OAuth2LoginAuthenticationProvider
4. loadUser 메서드 호출
이 순서대로 동작하는 것을 확인할 수 있다.
4번을 호출할 때 주입해 놓은 userService의 loadUser 메서드를 호출하게 된다. 이때!!!! 유저 정보에 접근할 수 있는 액세스토큰을 꺼내서 같이 넘겨준다.
여기서 아무 설정도 하지 않으면 DefaultOAuth2UserService를 주입시켜 동작하게 되지만, 나는 상속을 통해 PrincipalOAuth2UserService라는 것을 만들었어서 얘로 주입이 진행됐다.
그래서 유저 서비스가 PrincipalOAuth2UserService로 들어간 걸 확인할 수 있다.
그래서 아래와 같이 loadUser를 오버라이드를 해놨기 때문에 bp까지 진행이 완료된 것.
우선 부모의 loadUser를 호출해서 OAuth2User 객체를 하나 가져오게 된다. 이때 결국 DefaultOAuth2UserService의 loadUser가 호출되게 된다.
DefaultOAuth2UserService만 사용한 것이 아니라 상속을 통해 PrincipalOAuth2UserService를 만들었던 이유는 부모의 loadUser 메서드를 사용하고 밑에 추가적인 처리를 위해서 커스텀 UserService를 만든 것이다.(JWT 설정이나 기타 추가 작업을 진행하기 위하여)
여기서 OAuth2User는 인터페이스이다. 주석을 보면
OAuth 2.0 공급자에 등록된 사용자 Principal 의 표현입니다.
라고 적혀있다. 즉 인증된 사용자를 의미하는 것.
OAuth2User의 정보를 보면 아래와 같다.
뭐 별 거 없다(?)
이제 이 밑에부터는 내 서비스에 사용하려고 만들어놓은 코드이다.
우선 id를 얻어온다. 여기서는 카카오만 구현했기에 "kakao"라는 문자열을 얻게 된다.
String registrationId = userRequest.getClientRegistration().getRegistrationId();
그다음에 String에 따라 미리 만들어놓은 userInfo 클래스에 넣어서 생성해 주면 확장성에 좋다고 생각했다.
/*
* 확장성을 위함.
* kakao, naver, google 등등 다양한 타입에 맞게 수정만 해주면 됨.
* */
if ("kakao".equals(registrationId)) {
oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
}
물론 확장성이 안 좋다고는 할 수 없다. registrationId에 따라서 그에 맞는 UserInfo 클래스만 만들어서 받아주면 되기 때문에 확장성은 좋다고 생각한다.
그러나 피드백처럼 enum을 활용하고 메서드로 따로 뺀다면 조금 더 역할별로 분리시키고, 관리도 용이하게 만들 수 있다고 판단했다.
그래서 enum을 만드는데 추상메서드를 하나 오버라이드 하도록 만들었다.
public enum RegistrationId {
KAKAO {
@Override
public OAuth2UserInfo getOAuth2UserInfo(Map<String, Object> attributes) {
return new KakaoUserInfo(attributes);
}
}
public abstract OAuth2UserInfo getOAuth2UserInfo(Map<String, Object> attributes);
}
애초에 커스텀 UserInfo를 생성하는 부분을 추상으로 넣어서 오버라이드 시켰다. 그러면 String으로 받은 id를 enum으로 변환시키기 위해서 애초에 받을 때 대문자로 받아야 한다.
String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase();
그리고 대문자로 받은 String을 enum으로 변환시켜 준다.
RegistrationId regId = RegistrationId.valueOf(registrationId);
enum의 메서드를 호출해서 OAuth2UserInfo를 만들어주면 소셜에 맞는 객체를 생성할 수 있다.
OAuth2UserInfo oAuth2UserInfo = regId.getOAuth2UserInfo(oAuth2User.getAttributes());
그럼 기존에 비해 얼마나 확장성이 좋아졌을까?
아래 코드가 기존의 loadUser 메서드였다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// 소셜 타입에 맞게 유동적으로 담을 인터페이스 변수 생성
OAuth2UserInfo oAuth2UserInfo = null;
String registrationId = userRequest.getClientRegistration().getRegistrationId();
/*
* 확장성을 위함.
* kakao, naver, google 등등 다양한 타입에 맞게 수정만 해주면 됨.
* */
if ("kakao".equals(registrationId)) {
oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
}
String email = oAuth2UserInfo.getEmail();
// 블랙리스트에 있는지 확인
Optional<WithdrawalMember> byEmail = withdrawalMemberRepository.findByEmail(email);
if (byEmail.isPresent()) {
OAuth2Error error = new OAuth2Error("탈퇴한", email + " 유저가 로그인 시도를 진행했습니다.(탈퇴한 유저, 카카오)", null);
throw new OAuth2AuthenticationException(error);
}
String nickname = oAuth2UserInfo.getNickname();
// 유저가 db에 있는지 판단.
Optional<Member> byUser = memberRepository.findByEmail(email);
/*
* 만약에 유저가 없으면 회원가입 진행
* */
if (byUser.isEmpty()) {
Member member = new Member(email, nickname, SocialType.KAKAO);
Member save = memberRepository.save(member);
MemberRole memberRole = MemberRole.builder()
.member(save)
.build();
memberRoleRepository.save(memberRole);
} else {
Member member = byUser.get();
// 추방 여부 판단
if (member.getBannedDate() != null) {
OAuth2Error error = new OAuth2Error("추방된 유저", member.getEmail() + " 유저가 로그인 시도를 진행했습니다.(추방된 유저, 카카오)",
null);
throw new OAuth2AuthenticationException(error);
}
// 탈퇴 여부 판단
if (member.getWithdrawalDate() != null) {
OAuth2Error error = new OAuth2Error("탈퇴한 유저", member.getEmail() + " 유저가 로그인 시도를 진행했습니다.(탈퇴한 유저, 카카오)",
null);
throw new OAuth2AuthenticationException(error);
}
}
return oAuth2User;
}
일단 역할에 맞게 메서드 분리가 이루어지지 않았고, KAKAO만 생각하고 만들었어서 SocialType.KAKAO처럼 하드코딩으로 들어간 부분도 보인다.
밑에는 현재 수정을 완료한 코드이다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
OAuth2UserInfo oAuth2UserInfo = createOAuth2UserInfo(userRequest, oAuth2User);
String email = oAuth2UserInfo.getEmail();
checkBlackList(email); // 블랙리스트 확인
// 유저가 db에 있는지 판단.
Optional<Member> byUser = memberRepository.findByEmail(email);
/*
* 만약에 유저가 없으면 회원가입 진행
* */
if (byUser.isEmpty()) {
registerNewMember(oAuth2UserInfo, email);
} else {
checkExistingMember(byUser.get());
}
return oAuth2User;
}
// OAuth2UserInfo 생성
private OAuth2UserInfo createOAuth2UserInfo(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase();
try {
RegistrationId regId = RegistrationId.valueOf(registrationId);
return regId.getOAuth2UserInfo(oAuth2User.getAttributes(), regId);
} catch (IllegalArgumentException e) {
throw new OAuth2AuthenticationException(new OAuth2Error(INVALID_REGISTRATION_ID_ERROR, "지원하지 않는 registration id: " + registrationId, null));
}
}
// 블랙리스트에 있는지 확인
private void checkBlackList(String email) {
Optional<WithdrawalMember> byEmail = withdrawalMemberRepository.findByEmail(email);
if (byEmail.isPresent()) {
OAuth2Error error = new OAuth2Error(WITHDRAWAL_EMAIL_ERROR, email + " 유저가 로그인 시도를 진행했습니다.(탈퇴한 유저)", null);
throw new OAuth2AuthenticationException(error);
}
}
// 새로운 유저 가입
private void registerNewMember(OAuth2UserInfo oAuth2UserInfo, String email) {
String nickname = oAuth2UserInfo.getNickname();
Member member = new Member(email, nickname, SocialType.valueOf(oAuth2UserInfo.getRegistrationId().name()));
Member savedMember = memberRepository.save(member);
MemberRole memberRole = MemberRole.builder()
.member(savedMember)
.build();
memberRoleRepository.save(memberRole);
}
// 기존 유저 판단
private void checkExistingMember(Member member) {
// 추방 여부 판단
if (member.getBannedDate() != null) {
OAuth2Error error = new OAuth2Error(KICKED_MEMBER_ERROR, member.getEmail() + " 유저가 로그인 시도를 진행했습니다.(추방된 유저)",
null);
throw new OAuth2AuthenticationException(error);
}
// 탈퇴 여부 판단
if (member.getWithdrawalDate() != null) {
OAuth2Error error = new OAuth2Error(WITHDRAWAL_MEMBER_ERROR, member.getEmail() + " 유저가 로그인 시도를 진행했습니다.(탈퇴한 유저)",
null);
throw new OAuth2AuthenticationException(error);
}
}
우선 loadUser에 있던 기능들을 메서드로 따로 분리시켰다.
또한 다양한 타입을 받을 수 있도록 아래 코드를 통해 매핑을 시켜서 넣도록 변경했다.
SocialType.valueOf(oAuth2UserInfo.getRegistrationId().name())
만약에 SocialType에 없는 RegistrationId가 들어온다면?
이 생각도 했었지만 애초에 개발을 진행할 때 RegistrationId랑 SocialType은 일치하게 개발을 진행하고, 또한 위에서 RegistrationId 값이 존재하는지 try catch로 판단하고 있다. 그래서 문제가 없다고 판단했다.
전체적으로 코드를 개선하면서 확장성과 유지보수성을 한 단계 강화했다.
다음에 시간이 되면 새로운 소셜로그인을 추가했을 때 얼마나 간편하게 넣을 수 있는지 체감해보려고 한다.
'프로젝트 > RESTAPI 추천 서비스' 카테고리의 다른 글
RestTemplate -> RestClient 변경이 필요할까?(Feat, 부하테스트) (0) | 2024.07.06 |
---|---|
naver 소셜 로그인 추가하기(feat, 리팩토링) (0) | 2024.07.01 |
도커 허브를 추가하여 이미지 백업을 구성하기. (0) | 2024.06.23 |
배포를 위해 진행했던 도커와 젠킨스 정리 (0) | 2024.06.21 |