프로젝트/Petogram

[Petogram] Oauth Login refactoring 여행기 - 1

2023. 6. 16. 01:25
목차
  1. 들어가며
  2. 이전 로직
  3. 리팩토링
728x90

들어가며

이전에 Qhoto에서 구현했던 Oauth2.0을 구현하면서 아쉬웠던 점이 있었다.

Qhoto에서는 모든 로직을 GoogleLoginService, KakaoLoginService 함수 안에 중복된 로직이 존재했고 다른 Provider를 추가한다면 계속해서 중복된 로직을 작성해야 된다는 단점이 있었다.

사실 Oauth2.0을 구현하다 보면 Google, Kakao, Naver 등과 같은 Provider의 인증과정이 비슷하다는 것을 알 수 있다.

그리고 인증에 성공했을 때, 처리해야 하는 과정은 모든 Provider가 일치한다.

 

그럼 모든 공통된 로직과 Provider의 구현 전략들을 분리해 낼 수 있겠다는 결론에 도달했다.

디자인 패턴 중 어떤 디자인을 패턴을 적용해야 할까? 생각이 난 것은 템플릿 메서드 패턴과 전략 패턴이었다.

나는 이 두 패턴 중 전략 패턴을 사용했다. 그 이유는 템플릿 메서드는 상속을 사용하지만 전략 패턴은 합성을 사용하기 때문이다.

상속대신 합성을 더 지향하는 이유는 다음 링크에 정리해 놨었다.

 

전략 패턴을 사용하여 이 로직들을 분리해 보자.


이전 로직

Google Login

public LoginRes loginGoogle(String idTokenString) throws GeneralSecurityException, IOException {
        HttpTransport transport = new NetHttpTransport();
        JsonFactory jsonFactory = new JacksonFactory();
        // 구글 ID 토큰 validation
        GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport,jsonFactory)
                .setAudience(Collections.singletonList(GOOGLE_CLIENT_ID))
                .build();
        GoogleIdToken idToken = verifier.verify(idTokenString);

        if(idToken != null) {
            // validation 성공
            GoogleIdToken.Payload payload = idToken.getPayload();
            //payload를 가져온다.
            String email = payload.getEmail();
            String name = (String) payload.get("name");
            String pictureUrl = (String) payload.get("picture");

            //가입이 된 유저인지 판단하고 그에 맞는 정보를 가져온다.
            Map<String, Object> userMap = isJoined(email, name, pictureUrl,AuthProvider.GOOGLE);

            return LoginRes.builder()
                    .accessToken((String) userMap.get("accessToken"))
                    .refreshToken((String) userMap.get("refreshToken"))
                    .isJoined((Boolean) userMap.get("isJoined"))
                    .isModified((Boolean) userMap.get("isModified"))
                    .build();
        }
        else {
            // Google IdToken validation 실패
            throw new InvalidIdTokenException("Google TokenId is null.");
        }
    }

Kakao Login

public LoginRes loginKakao(String kakaoAccessToken) {
        // kakaoAccessToken 으로부터 사용자 정보를 가져온다.
        Map<String, String> userInfo = getUserInfo(kakaoAccessToken);

        String email = userInfo.get("email");
        String profileImage = userInfo.get("profileImage");
        String nickname = userInfo.get("nickname");

        log.info("nickname = {}", nickname);
        log.info("profileImage = {}", profileImage);
        log.info("email = {}", email);

        //가입이 된 유저인지 판단하고 그에 맞는 정보를 가져온다.
        Map<String, Object> userMap = isJoined(email, nickname, profileImage,AuthProvider.KAKAO);

        return LoginRes.builder()
                .accessToken((String) userMap.get("accessToken"))
                .refreshToken((String) userMap.get("refreshToken"))
                .isJoined((Boolean) userMap.get("isJoined"))
                .isModified((Boolean) userMap.get("isModified"))
                .build();
}
private Map<String, String> getUserInfo(String kakaoAccessToken) {

        // kakao access token validation 검증을 위해 api 호출을 한다.
        RestTemplate restTemplate = new RestTemplate();
        Map<String, String> userInfo = new HashMap<>();
        String reqURL = "https://kapi.kakao.com/v2/user/me";

        //request Body
        MultiValueMap<String,String> map = new LinkedMultiValueMap<>();

        //request Header
        MultiValueMap<String,String> headers = new LinkedMultiValueMap<>();

        //Header 추가
        headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
        headers.add("Authorization", "Bearer " + kakaoAccessToken);

        //Body 추가
        map.add("secure_resource", "true");
        HttpEntity<MultiValueMap<String,String>> entity = new HttpEntity<>(map,headers);

        // post 요청 결과를 받아온다.
        String result = restTemplate.postForObject(reqURL, entity, String.class);

        // 결과를 Json 파싱
        JsonElement jsonElement = JsonParser.parseString(result);
        JsonObject kakaoAcount = jsonElement.getAsJsonObject().get("kakao_account").getAsJsonObject();
        JsonObject profile = kakaoAcount.getAsJsonObject().get("profile").getAsJsonObject();

        // 회원 정보를 가져온다.
        String nickname = profile.getAsJsonObject().get("nickname").getAsString();
        String profileImage = profile.getAsJsonObject().get("profile_image_url").getAsString();
        String email = kakaoAcount.getAsJsonObject().get("email").getAsString();

        userInfo.put("nickname", nickname);
        userInfo.put("profileImage", profileImage);
        userInfo.put("email", email);

        return userInfo;
    }

인증 성공 시 호출하는 함수

// 가입이 되어있는 지 판단 후 그에 맞는 데이터를 가져온다.
    private Map<String,Object> isJoined(String email, String name, String pictureUrl,AuthProvider authProvider) {
        Map<String, Object> map = new HashMap<>();
        User user;

        // 이메일로 사용자 가입 여부 판단.
        Optional<User> findUser = userRepository.findByEmailAndAuthProvider(email, authProvider);
        Long userCount = userRepository.countByEmailAndAuthProvider(email, authProvider);
        //JWT 토큰
        String accessToken;
        String refreshToken;

        if(userCount > 1L) throw new NoUniqueUserException("해당 하는 사용자가 여러개 있습니다.");

        //가입이 되어 있는 지 판단.
        boolean isJoined = false;
        //가입후 초기 정보 설정이 되어 있는지 판단.
        boolean isModified = false;

        if(findUser.isPresent() && userCount==1L) {
            //가입된 유저가 있을 때
            user = findUser.get();

            // Token 발급
            accessToken = jwtTokenProvider.createAccessToken(email, user.getAuthorities());
            refreshToken = jwtTokenProvider.createRefreshToken();

            // 사용자의 RefreshToken 업데이트
            userRepository.updateRefreshToken(user.getId(), refreshToken);

            // 이미 가입된 사용자
            isJoined = true;
            if(!user.getPhone().equals("010-0000-0000")) {
                //초기 정보 설정이 되어있는 사용자
                isModified = true;
            }
        } else {
            // 사용자 생성
            user = createUser(email, name, pictureUrl,authProvider);
            // JWT 토큰 발급
            accessToken = jwtTokenProvider.createAccessToken(email, user.getAuthorities());
            refreshToken = jwtTokenProvider.createRefreshToken();
            // refresh 토큰 저장
            user.insertRefreshToken(refreshToken);
            //유저 저장
            userRepository.save(user);
        }

        map.put("refreshToken",refreshToken);
        map.put("accessToken", accessToken);
        map.put("isJoined",isJoined);
        map.put("isModified", isModified);
        return map;

    }

코드가 난잡하지만 안에 로직을 자세히 보면 전체적인 과정은 client에게 받은 token을 각 Provider에게 인증을 한 후 Payload를 받아와 이후 처리를 한다.

단지 Provider마다 google은 google client api를 통해, kakao는 restTemplate을 통해 인증 과정을 거친다.

 

위 코드와 같은 경우는 provider마다 함수를 생성 인증 전략을 작성하고 공통 로직을 처리하는 함수를 매번 호출해야 한다.

 


리팩토링

일단은 interface를 provider 마다 다른 전략을 적용할 수 있도록 분리해 보자.

public interface OauthService {
  Map<String, String> getPayload(String token);
  Object validation(String token);
}

토큰을 검증하는 validation 함수와 검증된 토큰을 통해 payload를 가져오는 과정은 provider마다 다르므로 분리해 냈다.

그리고 다음은 해당 interface를 구현한 GoogleOauthService.java이다.

@Service("GOOGLE")
public class GoogleOauthService implements OauthService {

  @Value("${oauth.google.client-id}")
  public static String GOOGLE_CLIENT_ID;

  @Value("${oauth.google.secret}")
  public static String GOOGLE_SECRET;

  @Override
  public Map<String, String> getPayload(String token) {
    // 구글 ID token validation
    GoogleIdToken idToken = (GoogleIdToken) validation(token);
    Map<String, String> payloadMap = new HashMap<>();

    if (idToken != null) {
      //validation 성공
      Payload payload = idToken.getPayload();
      // payload를 가져온다.
      String email = payload.getEmail();
      String name = (String) payload.get("name");

      payloadMap.put("email", email);
      payloadMap.put("name", name);
      payloadMap.put("provider", OAuth2Provider.GOOGLE.name());
    }
    return payloadMap;
  }

  @Override
  public Object validation(String token) {
    HttpTransport transport = new NetHttpTransport();
    JsonFactory jsonFactory = new JacksonFactory();

    GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
        .setAudience(Collections.singletonList(GOOGLE_CLIENT_ID))
        .build();
    GoogleIdToken idToken;
    try {
      idToken = verifier.verify(token);
    } catch (Exception e) {
      throw new InvalidGoogleToken("구글 토큰이 유효하지 않습니다.");
    }
    return idToken;
  }
}

 

이제 해당 로직을 공통 로직이 있는 OauthLoginServiceImpl.java에 합성하자.

 

OauthLoginServiceImpl.java

@Service
@RequiredArgsConstructor
@Profile("!test")
@Transactional
public class OauthLoginServiceImpl implements OauthLoginService {

  //동적으로 빈 사용
  private final Map<String, OauthService> oauthServiceMap;
  private final UserRepository userRepository;

  private final BCryptPasswordEncoder bCryptPasswordEncoder;

  public User attemptLogin(LoginReqDto loginReqDto) {
    OauthService oauthService = oauthServiceMap.get(loginReqDto.getProvider().toUpperCase());
    Map<String, String> payload = oauthService.getPayload(loginReqDto.getToken());
    String email = payload.get("email");
    String name = payload.get("name");

    try {
      // 이미 가입된 사용자가 있을 경우에
      return getJoinedUser(email);
    } catch (Exception e) {
      // 가입된 사용자가 없을 경우에 가입한다.
      return joinUser(loginReqDto, email, name);
    }
  }

  private User joinUser(LoginReqDto loginReqDto, String email, String name) {
    User user = User.createCustomer(email, name,
        bCryptPasswordEncoder.encode("DEFAULT_PASSWORD"),
        OAuth2Provider.valueOf(loginReqDto.getProvider().toUpperCase()));
    userRepository.save(user);
    return user;
  }

  private User getJoinedUser(String email) {
    return userRepository.findByEmail(email).orElseThrow(() ->
        new NoUserByEmail("이메일에 해당하는 사용자가 없습니다."));
  }
}

OauthServiceImpl.java에 OauthService.java를 Map 데이터타입으로 합성한 것을 볼 수 있다.

그 이유는 attemptLogin()을 호출할 때, 해당 request dto의 데이터를 보고 동적으로 어떤 Provider의 전략을 사용할지 결정하기 위해서 Map 데이터타입으로 합성을 했다.

 

oauthService.getPayload() 함수로 provider 마다 다른 인증 과정을 거쳐 공통로직인 joinUser()와 getJoinedUser()를 통해 회원가입이 되어 있는 경우와 안되어 있는 경우를 분기하여 처리할 수 있었다.

 

이를 통해 얻을 수 있는 장점은 또 있다.

OauthSerivice.java를 implements 하는 클래스들은 provider가 제공하는 api를 활용하거나 rest api를 통해 호출하는 등 내가 제어할 수 없는 코드이다. 이렇게 제어할 수 없는 코드를 분리함으로써 좀 더 유연하게 테스트를 할 수 있다.

 

제어할 수 없는 것에 의존하지 않기(https://brightstarit.tistory.com/45)

'프로젝트 > Petogram' 카테고리의 다른 글

[Petogram] Oauth Login refactoring 여행기 - 2  (0) 2023.06.17
  1. 들어가며
  2. 이전 로직
  3. 리팩토링
'프로젝트/Petogram' 카테고리의 다른 글
  • [Petogram] Oauth Login refactoring 여행기 - 2
밝은별 개발자
밝은별 개발자
호기심 가득한 밤을 하나씩 밝히는 밝은별입니다.
밝은별 개발 블로그호기심 가득한 밤을 하나씩 밝히는 밝은별입니다.
밝은별 개발자
밝은별 개발 블로그
밝은별 개발자
전체
오늘
어제
  • 전체 글 (59)
    • 자소서 (0)
    • 백엔드 (55)
      • Java (15)
      • 네트워크 (5)
      • JPA (11)
      • Spring (6)
      • 운영체제 (8)
      • MSA (4)
      • etc (4)
      • 데이터베이스 (2)
    • 프로젝트 (3)
      • Petogram (2)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 변경 감지
  • 런타임데이터영역
  • OS
  • db
  • 네트워크
  • java8
  • Redis
  • 쓰기 지연
  • 상속
  • 캐시
  • Spring
  • 이펙티브자바
  • MSA
  • HTTP
  • 데이터베이스
  • 영속성컨텍스트
  • 디자인 패턴
  • EUREKA
  • ORM
  • pojo
  • Java
  • JPA
  • JDK 동적프록시
  • 클린 아키텍처 #spring
  • 메모리
  • 스트림
  • Service discovery
  • 운영체제
  • Petogram
  • 객체지향

최근 댓글

최근 글

hELLO · Designed By 정상우.
밝은별 개발자
[Petogram] Oauth Login refactoring 여행기 - 1
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.