들어가며
이전에 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 |
---|