TIL

24.08.08 TIL - 스프링 소셜 로그인 코드 리팩토링

개발공명 2024. 8. 9. 07:56

 

개요

프로젝트에 카카오, 구글 소셜 로그인을 적용하려고 했다. 

 

우선 카카오 로그인을 적용해 두었다. 

 

Controller, Service의 코드는 아래와 같다. 

 

OAuthController

...

@Controller
@Slf4j
@RequiredArgsConstructor
public class OAuthController {

    private final KakaoUtil kakaoUtil;	// 카카오 로그인에 필요한 메서드 모아 놓은 클래스
    private final OAuthService oauthService;
    private final RegisterService registerService;

    @GetMapping("/login/kakao")
    public String moveToKakaoLogin() {
        String location = kakaoUtil.getKakaoUrl();
        // location = https://kauth.kakao.com/oauth/authorize.....
        return "redirect:" + location;
    }
	
    // redirect uri = http://localhost:8080/kakao/callback
    @GetMapping("/kakao/callback")
    public ResponseEntity loginByKakao(@RequestParam("code") String code, HttpServletRequest request) {
        KakaoLoginResponseDto kakaoLoginResponseDto = oauthService.loginByKakao(code, request);
        // redirect uri의 쿼리 파라미터로 온 code를 service로 넘겨 카카오 로그인 수행하는 것
        return ResponseEntity.ok(kakaoLoginResponseDto);
    }
	
    ...
}

 

카카오 로그인 처음 진행할 때 아래 url로 요청 보내야 한다. 

 

하지만 client_id와 redirect_uri 모두 나의 것이다.

 

따라서 프론트에서 바로 아래 url로 요청 보내는 것이 아니라 지정된 api 호출하면 백엔드에서 이 주소로 redirect 하도록 해줬다. 

"https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=client_id&redirect_uri=redirect_uri"

 

OAuthService

...

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class OAuthService {

    private final KakaoUtil kakaoUtil;
    private final MemberRepository memberRepository;

    public KakaoLoginResponseDto loginByKakao(String code, HttpServletRequest request) {
        KakaoLoginResponseDto kakaoLoginResponseDto = new KakaoLoginResponseDto();
        // KakaoLoginResponseDto -> email, nickname, loginStatus 필드로 가짐

        try {
            String accessToken = kakaoUtil.getAccessToken(code);
            KakaoDto kakaoDto = kakaoUtil.getUserInfoWithToken(accessToken);
            // KakaoDto -> email, nickname 필드로 가짐
            
            // 카카오 정보 = email, nickname 가지고 이후 회원가입 or 로그인 처리
            LoginStatus loginStatus = checkMemberRegistration(kakaoDto, request);
            // LoginStatus = LOGIN_SUCCESS, NEED_REGISTER, SAME_EMAIL, SAME_NICKNAME, LOGIN_FAIL 가지는 enum 클래스 

            kakaoLoginResponseDto.setEmail(kakaoDto.getEmail());
            kakaoLoginResponseDto.setNickname(kakaoDto.getNickname());
            kakaoLoginResponseDto.setLoginStatus(loginStatus);

            return kakaoLoginResponseDto;
            
        } catch (JsonProcessingException e) {
            log.info("카카오 로그인 예외로 실패");
            
            kakaoLoginResponseDto.setEmail("");
            kakaoLoginResponseDto.setNickname("");
            kakaoLoginResponseDto.setLoginStatus(LOGIN_FAIL);
            
            return kakaoLoginResponseDto;
        }
    }

    private LoginStatus checkMemberRegistration(KakaoDto kakaoDto, HttpServletRequest request) {
    	...
        // 카카오 로그인 정보 = 이메일, 닉네임 가지고 회원 가입 또는 로그인 처리 로직
    }

}

 

OAuthService에서는 AccessToken을 얻고 AccessToken으로 카카오 정보 = email, nickname 가지고 이후 회원가입 or 로그인 처리를 하는 것이다. 

 

loginByKakao 메서드에서 예외처리를 하려다 보니 KakaoLoginResponseDto에 값을 채우는 코드가 뭔가 지저분하게 되어 있었다. 

 

메서드 자체도 너무 길고 보기도 싫다는 생각이 있었다. 

 

 

구글 로그인 추가 

구글 로그인을 추가하는데 카카오 로그인과 유사한 점이 굉장히 많았다

 

우선 소셜 로그인의 진행 순서가 아래와 같다. 

 

  1. 지정된 url로 지정된 값과 함께 요청
    1. 카카오의 경우는 아래 url로 client_id, redirect_uri 정보 넣어서 요청 보내야 함
      https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=client_id&redirect_uri=redirect_uri
    2.  구글의 경우 아래 url로 client_id, redirect_uri, scope 정보 넣어서 요청 보내야 함
      https://accounts.google.com/o/oauth2/v2/auth?client_id=client_id&redirect_uri=redirect_uri&response_type=code&scope=email%20profile
  2. 아래 url로 요청 보내면 소셜 로그인 창이 뜨고 로그인 진행한다.

  3. 로그인 성공 시 redirect_uri 뒤에 쿼리 파라미터로 code 값 붙여서 요청 보낸다. 

  4. 지정된 url로 Http Request Body에 필요한 값 (code 등) 넣어 요청을 보낸다. 
    1. 카카오의 경우 https://kauth.kakao.com/oauth/token 로 요청 보내야 함.
    2. 구글의 경우 https://oauth2.googleapis.com/token 로 요청 보내야 함
    3. 나의 경우 RestTemplate로 요청 보냄
  5. 그러면 응답으로 json 오는데 여기에 access token이 있어 파싱한다. 

  6. 지정된 url로 Http Request Body에 필요한 값 (access token 등) 넣어 요청 보낸다. 
    1. 카카오의 경우 https://kapi.kakao.com/v2/user/me 로 요청 보내야 함
    2. 구글의 경우 https://oauth2.googleapis.com/tokeninfo 로 요청 보내야 함
  7. 그러면 역시 응답으로 json 오는데 여기에 필요한 값 (email, name 등) 있어 파싱해서 사용한다. 

 

구글, 카카오만 비슷한걸 수도 있지만 아마 대부분 다 이런 순서가 아닐까 생각이 든다. 

 

 

고민

그리고 구글 로그인에서도 사용자 정보를 받은 후 진행하는 로직이 카카오 로그인에서 사용자 정보를 받은 후 진행하는 로직과 동일했다. 

 

즉 똑같이 위의 OAuthService의 checkMemberRegistration 메서드를 진행해야 했다. 

 

그리고 얻어오는 사용자 정보도 email, nickname으로 같다.

 

그리고 다시 클라이언트로 보내는 응답도 email, nickname, loginStatus로 같았다. 

 

 

그런데 OAuthService의 loginByKakao 메서드는 카카오 로그인에 종속적으로 구현되어 있다. 

 

여기서 어떻게 하면 구글, 카카오 로그인을 동시에 잘 처리할 수 있을지 고민했다. 

 

 

step 1

그렇다면 카카오 로그인 요청인지, 구글 로그인 요청인지 구분할 수 있는 것을 controller에서 service로 같이 넘겨 if문에 따라 처리를 다르게 하면 될 것 같았다. 

 

그래서 어떤 로그인인지 나타내는 Enum 클래스 OAuthType을 만들었다. 

 

추후에 다른 소셜 로그인을 추가해도 쉽게 추가할 수 있도록 Enum 클래스를 생성했다. 

public enum OAuthType {
    KAKAO,
    GOOGLE
}

 

그리고 우선 Dto 이름이나 변수명, 메서드명도 수정했다. 

 

이전에 카카오 로그인에 종속적이라 이름에 Kakao가 들어갔는데 이제 소셜 로그인에 관한 것이니 아래와 같이 수정했다. 

이전 이후
KakaoLoginResponseDto OAuthLoginResponseDto
KakaoDto OAuthLoginInfoDto
loginByKakao loginByOAuth

 

그러면 이제 메서드에서 OAuthType에 따라 다르게 처리하면 된다. 

 

수정된 OAuthService 코드는 아래와 같다. 

...

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class OAuthService {

    private final KakaoUtil kakaoUtil;
    private final GoogleUtil googleUtil;
    private final MemberRepository memberRepository;

    public OAuthLoginResponseDto loginByOAuth(String code, HttpServletRequest request, OAuthType oauthType) {
        OAuthLoginResponseDto oAuthLoginResponseDto = new OAuthLoginResponseDto();
        // OAuthLoginResponseDto -> email, nickname, loginStatus 필드로 가짐

        try {
        	// OAuthType에 따라 소셜 로그인 처리 다르게 
            String accessToken;
            OAuthLoginInfoDto oAuthLoginInfoDto = null;
            if (oauthType == KAKAO) {
                accessToken = kakaoUtil.getAccessToken(code);
                OAuthLoginInfoDto = kakaoUtil.getUserInfoWithToken(accessToken);
            } else if (oauthType == GOOGLE) {
                accessToken = googleUtil.getAccessToken(code);
                OAuthLoginInfoDto = googleUtil.getUserInfoWithToken(accessToken);
            }
            
            LoginStatus loginStatus = checkMemberRegistration(oAuthLoginInfoDto, request);

            oAuthLoginResponseDto.setEmail(oAuthLoginInfoDto.getEmail());
            oAuthLoginResponseDto.setNickname(oAuthLoginInfoDto.getNickname());
            oAuthLoginResponseDto.setLoginStatus(loginStatus);

            return oAuthLoginResponseDto;
            
        } catch (JsonProcessingException e) {
            log.info(oauthType + " 로그인 예외로 실패");
            
            oAuthLoginResponseDto.setEmail("");
            oAuthLoginResponseDto.setNickname("");
            oAuthLoginResponseDto.setLoginStatus(LOGIN_FAIL);
            
            return oAuthLoginResponseDto;
        }
    }

    private LoginStatus checkMemberRegistration(OAuthLoginInfoDto oAuthLoginInfoDto, HttpServletRequest request) {
    	...
        // 소셜 로그인 정보 = 이메일, 닉네임 가지고 회원 가입 또는 로그인 처리 로직
    }

}

 

OAuthType을 적용해 이제 소셜 로그인 후 받아온 정보 = email, nickname을 가지고 회원 가입 또는 로그인 처리하는 로직과 클라이언트로 반환할 응답 값 생성하는 로직을 함께 사용할 수 있게 되었다. 

 

step 2

이제 다른 소셜 로그인을 추가하더라고 if문에 else if만 하나 더 추가하면 된다. 

 

그런데 메서드가 매우 지저분하다. 

 

메서드를 분리할 수 있을 것 같았다. 

 

우선 이 OAuthType에 따라 다르게 처리하는 부분을 먼저 메서드로 분리했다. 

 

메서드 분리한 코드는 아래와 같다. 

...

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class OAuthService {

    private final KakaoUtil kakaoUtil;
    private final GoogleUtil googleUtil;
    private final MemberRepository memberRepository;

    public OAuthLoginResponseDto loginByOAuth(String code, HttpServletRequest request, OAuthType oauthType) {
        OAuthLoginResponseDto oAuthLoginResponseDto = new OAuthLoginResponseDto();
        // OAuthLoginResponseDto -> email, nickname, loginStatus 필드로 가짐

        try {
            // OAuthType에 따라 소셜 로그인 처리 다르게 
            // 메서드 분리
            OAuthLoginInfoDto OAuthLoginInfoDto = getOAuthLoginInfoDto(code, oauthType);
           	
            LoginStatus loginStatus = checkMemberRegistration(oAuthLoginInfoDto, request);

            oAuthLoginResponseDto.setEmail(oAuthLoginInfoDto.getEmail());
            oAuthLoginResponseDto.setNickname(oAuthLoginInfoDto.getNickname());
            oAuthLoginResponseDto.setLoginStatus(loginStatus);

            return oAuthLoginResponseDto;
            
        } catch (JsonProcessingException e) {
            log.info(oauthType + " 로그인 예외로 실패");
            
            oAuthLoginResponseDto.setEmail("");
            oAuthLoginResponseDto.setNickname("");
            oAuthLoginResponseDto.setLoginStatus(LOGIN_FAIL);
            
            return oAuthLoginResponseDto;
        }
    }
    
    // 분리한 메서드
    private OAuthLoginInfoDto getOAuthLoginInfoDto(String code, OAuthType oauthType) throws JsonProcessingException {
        String accessToken;
        OAuthLoginInfoDto OAuthLoginInfoDto = null;
        if (oauthType == KAKAO) {
            accessToken = kakaoUtil.getAccessToken(code);
            OAuthLoginInfoDto = kakaoUtil.getUserInfoWithToken(accessToken);
        } else if (oauthType == GOOGLE) {
            accessToken = googleUtil.getAccessToken(code);
            OAuthLoginInfoDto = googleUtil.getUserInfoWithToken(accessToken);
        }
        return OAuthLoginInfoDto;
    }

    private LoginStatus checkMemberRegistration(OAuthLoginInfoDto oAuthLoginInfoDto, HttpServletRequest request) {
    	...
        // 소셜 로그인 정보 = 이메일, 닉네임 가지고 회원 가입 또는 로그인 처리 로직
    }

}

 

메서드를 분리하여 이제 소셜 로그인을 추가하면 getOAuthLoginInfoDto 메서드만 수정하면 된다. 

 

step 3

그런데 여전히 loginByOAuth 메서드가 여전히 더럽다. 

 

OAuthLoginResponseDto를 만드는 코드가 있는 것이 아주 보기 싫었다. 

 

또한 try-catch문 때문에 두번 있는 것try-catch문 밖에 OAuthLoginResponseDto 를 생성하는 것이 있는 것이 매우 보기 싫었다. 

 

이것도 메서드로 분리할 수 있을 것 같았다. 

 

OAuthLoginResponseDto 를 만드는데 필요한 값은 email, nickname = OAuthLoginInfoDto 와 LoginStatus였다. 

 

이것들을 메서드 파라미터로 넘겨주면 될 것 같았다. 

 

따라서 메서드 분리한 코드는 아래와 같다. 

...

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class OAuthService {

    private final KakaoUtil kakaoUtil;
    private final GoogleUtil googleUtil;
    private final MemberRepository memberRepository;

    public OAuthLoginResponseDto loginByOAuth(String code, HttpServletRequest request, OAuthType oauthType) {

        try {
            OAuthLoginInfoDto OAuthLoginInfoDto = getOAuthLoginInfoDto(code, oauthType);
            
            LoginStatus loginStatus = checkMemberRegistration(OAuthLoginInfoDto, request);
            
            log.info(OAuthLoginInfoDto.getEmail() + " " + OAuthLoginInfoDto.getNickname() + " " + loginStatus);
            
            return makeOAuthResponse(OAuthLoginInfoDto, loginStatus);
            
        } catch (JsonProcessingException e) {
            log.info(oauthType + " 로그인 예외로 실패");
            
            return makeOAuthResponse(new OAuthLoginInfoDto("", ""), LOGIN_FAIL);
        }
    }

    private OAuthLoginInfoDto getOAuthLoginInfoDto(String code, OAuthType oauthType) throws JsonProcessingException {
        String accessToken;
        OAuthLoginInfoDto OAuthLoginInfoDto = null;
        
        if (oauthType == KAKAO) {
            accessToken = kakaoUtil.getAccessToken(code);
            OAuthLoginInfoDto = kakaoUtil.getUserInfoWithToken(accessToken);
        } else if (oauthType == GOOGLE) {
            accessToken = googleUtil.getAccessToken(code);
            OAuthLoginInfoDto = googleUtil.getUserInfoWithToken(accessToken);
        }
        
        return OAuthLoginInfoDto;
    }

    private OAuthLoginResponseDto makeOAuthResponse(OAuthLoginInfoDto oAuthLoginInfoDto, LoginStatus loginStatus) {
        OAuthLoginResponseDto oAuthLoginResponseDto = new OAuthLoginResponseDto();
        
        oAuthLoginResponseDto.setEmail(oAuthLoginInfoDto.getEmail());
        oAuthLoginResponseDto.setNickname(oAuthLoginInfoDto.getNickname());
        oAuthLoginResponseDto.setLoginStatus(loginStatus);
        
        return oAuthLoginResponseDto;
    }

    private LoginStatus checkMemberRegistration(OAuthLoginInfoDto OAuthLoginInfoDto, HttpServletRequest request) {
    	...
        // 소셜 로그인 정보 = 이메일, 닉네임 가지고 회원 가입 또는 로그인 처리 로직
    }

}

 

OAuthLoginResponseDto를 만드는 메서드 makeOAuthResponse를 만들고 파라미터로 OAuthLoginInfoDto, LoginStatus를 넘겼다. 

 

그래서 loginByOAuth 메서드가 굉장히 깔끔해진 것 같다. 

 

응답을 만들어서 반환하는 코드를 한 줄로 처리해서 try-catch문에서 반환하는 부분이 매우 깔끔해진 것 같다. 

 

 

ToDo

코드가 좀 더 깔끔해지고 추후 유지보수 할 때 좀 더 괜찮아진 것 같다고 생각한다. 

 

하지만 아직 더 수정할 것들이 보이는 것 같다. 

 

아직 예외처리에 대해서 공부를 다 마치지 못해서 잘 모르지만 예외처리 부분도 좀 더 수정해야 할 것 같다. 

 

또한 현재 프로젝트에서 세션 기반의 로그인 방식을 사용해서 지금 메서드의 파라미터로 계속 HttpServletRequest를 넘기는 것을 볼 수 있다. 

 

이 부분도 굉장히 문제점인 것 같다. 

 

이 부분을 어떻게 수정해야 할지 좀 더 공부하고 고민해봐야 할 것 같다.