카카오 + 네이버 소셜 로그인 추상 템플릿 메서드 패턴으로 구현하기

김지인·2023년 1월 16일
0

들어가기 앞서서 ..

대표적인 소셜로그인이라면 구글, 페이스북, 카카오, 네이버 정도가 있겠다. 이 4개의 소셜로그인 구현은 대부분 비슷한 프로세스를 가진다.

  1. 클라이언트단에서 사용자가 로그인을 성공하면 인가코드를 받아온다.
  2. 해당 코드를 통해 소셜로그인 api 서버에 토큰을 요청한다.
  3. 토큰을 받아오면 해당 토큰으로 소셜로그인 resource 서버에 정보를 요청한다.

그렇다면 공통되는 저 세가지의 큰 프로세스를 템플릿으로 나눌 수 없을까? 하는 생각에서 구현을 했다.


그러면 어떻게 틀을 나눌까 ?

	protected abstract String getAccessToken(String code);
    --- 1
	protected abstract HashMap<String, String> getUserInfoFromKakaoResource(String accessToken);
	--- 2
	protected abstract HashMap<String, String> setUserInfo(JsonElement userInfoJson); 
	--- 3
	protected abstract ModelAndView setLogin(HashMap<String, String> userInfo);
    --- 4
  1. 인가 코드를 이용해서 토큰을 받아오는 메서드
  2. 해당 토큰으로 response을 받아오는 메서드
  3. 받아온 response에서 유저 정보를 가져오는 메서드
  4. 해당 정보로 로그인을 진행하는 메서드

각 소셜로그인 마다 요청의 방법이 조금씩 다르므로 추상메서드로 선언해줫다. 그 후에 이 네가지의 로직을 처리해줄 공통 메서드를 만든다.

	public ModelAndView socialLogin(String code) {			
		String accessToken = getAccessToken(code); --- 1
        
		HashMap<String, String>userInfo = getUserInfoFromKakaoResource(accessToken); --- 2
		
		return setLogin(userInfo); --- 3
	}
  1. 소셜로그인이 성공했다는 가정 하에 getAccessToken(code) 메서드에서 토큰을 받아온다.
  2. 두번째 getUserInfoFromKakaoResource(accessToken) 메서드에서 유저 정보를 맵 형식으로 받아온다.
  3. 마지막 setLogin(userInfo)에서 로그인 처리를 해준다.

그리고 json 객체를 파싱해줄 메서드를 공통 메서드로 분리했다.

	protected String asString(String data,String dataname) {
        try{
            JsonParser parser = new JsonParser();
            JsonElement element = parser.parse(data);
            return element.getAsJsonObject().get(dataname).getAsString();
        } catch(Exception e) {
            log.error("not JsonObject");
        }
        return "not JsonObject";
    }

SocialLoginServiceTemplate.java

@Slf4j
public abstract class SocialLoginServiceTemplate {
	
	@Autowired
	protected BCryptPasswordEncoder bCryptPasswordEncoder;
	
	@Autowired
	protected LoginDAO loginDAO;
	
	public ModelAndView socialLogin(String code) {			
		String accessToken = getAccessToken(code);
		HashMap<String, String>userInfo = getUserInfoFromKakaoResource(accessToken);
		
		return setLogin(userInfo);
	}
	
	protected abstract String getAccessToken(String code);
	
	protected abstract HashMap<String, String> getUserInfoFromKakaoResource(String accessToken);
	
	protected abstract HashMap<String, String> setUserInfo(JsonElement userInfoJson); 
	
	protected abstract ModelAndView setLogin(HashMap<String, String> userInfo);
	
	
	protected String asString(String data,String dataname) {
        try{
            JsonParser parser = new JsonParser();
            JsonElement element = parser.parse(data);
            return element.getAsJsonObject().get(dataname).getAsString();
        } catch(Exception e) {
            log.error("not JsonObject");
        }
        return "not JsonObject";
    }
}

DAO객체 또한 공통으로 사용해야 하므로 Template단에서 선언해줫다.


세부 구현

각각 위에서 만든 SocialLoginServiceTemplate을 상속받는 클래스를 만들어준다.

<KAKAO>
@Service
public class KakaoLoginService extends SocialLoginServiceTemplate

<NAVER>
@Service
public class NaverLoginService extends SocialLoginServiceTemplate

getAccessToken(String code)

	<KAKAO>
	@Override
	protected String getAccessToken(String code) {
		
		HttpHeaders headers = new HttpHeaders();
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
		accessTokenBodyInfo.add("grant_type", "authorization_code");
		accessTokenBodyInfo.add("client_id", "${REST_API_KEY}");
		accessTokenBodyInfo.add("redirect_uri", "http://localhost:8080/kakao.lo");
		accessTokenBodyInfo.add("code", code);
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://kauth.kakao.com/oauth/token",
				HttpMethod.POST, 
				new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
				String.class
				);
		return  asString(response.getBody(),"access_token");
	}
    ----------------------------------------------------------------------------------

	<NAVER>
    @Override
	protected String getAccessToken(String code) {
		HttpHeaders headers = new HttpHeaders();
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
		accessTokenBodyInfo.add("grant_type", "authorization_code");
		accessTokenBodyInfo.add("client_id", "${client_id}");
		accessTokenBodyInfo.add("client_secret", "${client_secret}");
		accessTokenBodyInfo.add("code", code);
		accessTokenBodyInfo.add("state","STATE_STRING");
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://nid.naver.com/oauth2.0/token",
				HttpMethod.POST, 
				new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
				String.class
				);
		return asString(response.getBody(),"access_token");
		
	}

카카오, 네이버 두 개의 소셜로그인의 토큰을 받아오는 메서드이다. 세부 구현은 약간씩 다르지만 큰 틀은 비슷한 것을 볼 수 있다.


getUserInfoFromKakaoResource(String accessToken)


	<KAKAO>
	@Override
	protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
		
		HttpHeaders headers = new HttpHeaders();
		headers.add("Authorization", "Bearer "+accessToken);
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://kapi.kakao.com/v2/user/me",
				HttpMethod.POST, 
				new HttpEntity<String>(headers),
				String.class
				);
			
		return setUserInfo(new JsonParser().parse(response.getBody()));
	}
    
-----------------------------------------------------------------------------------------------
	<NAVER>
    @Override
	protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
		HttpHeaders headers = new HttpHeaders();
		headers.add("Authorization", "Bearer "+accessToken);
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://openapi.naver.com/v1/nid/me",
				HttpMethod.POST, 
				new HttpEntity<String>(headers),
				String.class
				);
		
		return setUserInfo(new JsonParser().parse(response.getBody()));
	}

두 소셜로그인 모두 세부적인 구현만 다르고 큰 틀은 같은것을 볼 수 있다. 마지막에 공통메서드인 setUserInfo()를 통해 맵을 반환하는 것을 볼 수 있다.


setUserInfo(JsonElement userInfoJson)

	<KAKAO>
    @Override
	protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
		HashMap<String, String> userInfo = new HashMap<String, String>();
		userInfo.put("provider", "KAKAO");
		userInfo.put("providerId", userInfoJson.getAsJsonObject().get("id").getAsString());
		userInfo.put("username", "KAKAO"+"_"+userInfoJson.getAsJsonObject().get("id").getAsString());
		userInfo.put("password", "null");
		return userInfo;
	}
-----------------------------------------------------------------------------------------------
	<NAVER>
    @Override
	protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
		HashMap<String, String> userInfo = new HashMap<String, String>();
		userInfo.put("provider", "NAVER");
		userInfo.put("providerId", userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
		userInfo.put("username", "NAVER"+"_"+userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
		userInfo.put("password", "null");
		return userInfo;
	}

각 소셜로그인의 유저 정보의 json형태는 다르기 때문에 각 소셜로그인에 맞춰서 json객체를 String으로 뽑아준 후 맵키를 동일하게 하고 해당 데이터를 넣어줘서 반환하는 것을 볼 수 있다.


setLogin(HashMap<String, String> userInfo)

	<KAKAO>
    @Override
	protected ModelAndView setLogin(HashMap<String, String> userInfo) {
		Member checkMember = loginDAO.findUser(userInfo.get("username"));

		if(checkMember==null) {
		
			checkMember = Member.builder()
						.username(userInfo.get("username"))
						.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
						.nickName("null")
						.email("null")
						.status("Y")
						.orange(0L)
						.role("ROLE_USER")
						.provider(userInfo.get("provider"))
						.providerId(userInfo.get("providerId"))
						.build();
		
			loginDAO.registerMember(checkMember);

		}
		
		Member member = loginDAO.findUser(checkMember.getUsername());

		ModelAndView mv = new ModelAndView();
		mv.addObject("username", member.getUsername());
		mv.addObject("password", member.getPassword());
		mv.setViewName("/login/social");
		return mv;
	}
    
-------------------------------------------------------------------------------------------------
	<NAVER>
    @Override
	protected ModelAndView setLogin(HashMap<String, String> userInfo) {
		Member checkMember = loginDAO.findUser(userInfo.get("username"));
		
		if(checkMember==null) {
		
			checkMember = Member.builder()
						.username(userInfo.get("username"))
						.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
						.nickName("null")
						.email("null")
						.status("Y")
						.orange(0L)
						.role("ROLE_USER")
						.provider(userInfo.get("provider"))
						.providerId(userInfo.get("providerId"))
						.build();
		
			loginDAO.registerMember(checkMember);

		}		
		
		Member member = loginDAO.findUser(checkMember.getUsername());
		
		ModelAndView mv = new ModelAndView();
		mv.addObject("username", member.getUsername());
		mv.addObject("password", member.getPassword());
		mv.setViewName("/login/social");
		return mv;
	}

두 메서드 동일하게 기존의 유저가 없으면 강제로 회원가입을 진행 후에 model 객체로 담아 주었다.


KakaoLoginService.java

@Service
@Slf4j
public class KakaoLoginService extends SocialLoginServiceTemplate{

	@Override
	protected String getAccessToken(String code) {
		
		HttpHeaders headers = new HttpHeaders();
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
		accessTokenBodyInfo.add("grant_type", "authorization_code");
		accessTokenBodyInfo.add("client_id", "${REST_API_KEY}");
		accessTokenBodyInfo.add("redirect_uri", "http://localhost:8080/kakao.lo");
		accessTokenBodyInfo.add("code", code);
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://kauth.kakao.com/oauth/token",
				HttpMethod.POST, 
				new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
				String.class
				);
		return  asString(response.getBody(),"access_token");
	}
	
	@Override
	protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
		
		HttpHeaders headers = new HttpHeaders();
		headers.add("Authorization", "Bearer "+accessToken);
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://kapi.kakao.com/v2/user/me",
				HttpMethod.POST, 
				new HttpEntity<String>(headers),
				String.class
				);
			
		return setUserInfo(new JsonParser().parse(response.getBody()));
	}


	@Override
	protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
		HashMap<String, String> userInfo = new HashMap<String, String>();
		userInfo.put("provider", "KAKAO");
		userInfo.put("providerId", userInfoJson.getAsJsonObject().get("id").getAsString());
		userInfo.put("username", "KAKAO"+"_"+userInfoJson.getAsJsonObject().get("id").getAsString());
		userInfo.put("password", "null");
		return userInfo;
	}
	
	@Override
	protected ModelAndView setLogin(HashMap<String, String> userInfo) {
		Member checkMember = loginDAO.findUser(userInfo.get("username"));

		if(checkMember==null) {
		
			checkMember = Member.builder()
						.username(userInfo.get("username"))
						.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
						.nickName("null")
						.email("null")
						.status("Y")
						.orange(0L)
						.role("ROLE_USER")
						.provider(userInfo.get("provider"))
						.providerId(userInfo.get("providerId"))
						.build();
		
			loginDAO.registerMember(checkMember);

		}
		
		Member member = loginDAO.findUser(checkMember.getUsername());

		ModelAndView mv = new ModelAndView();
		mv.addObject("username", member.getUsername());
		mv.addObject("password", member.getPassword());
		mv.setViewName("/login/social");
		return mv;
	}

}
@Service
public class NaverLoginService extends SocialLoginServiceTemplate{

	
	@Override
	protected String getAccessToken(String code) {
		HttpHeaders headers = new HttpHeaders();
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		MultiValueMap<String, String> accessTokenBodyInfo = new LinkedMultiValueMap<String, String>();
		accessTokenBodyInfo.add("grant_type", "authorization_code");
		accessTokenBodyInfo.add("client_id", "${client_id}");
		accessTokenBodyInfo.add("client_secret", "${client_secret}");
		accessTokenBodyInfo.add("code", code);
		accessTokenBodyInfo.add("state","STATE_STRING");
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://nid.naver.com/oauth2.0/token",
				HttpMethod.POST, 
				new HttpEntity<MultiValueMap<String, String>>(accessTokenBodyInfo, headers),
				String.class
				);
		return asString(response.getBody(),"access_token");
		
	}

	@Override
	protected HashMap<String, String> getUserInfoFromKakaoResource(String accessToken) {
		HttpHeaders headers = new HttpHeaders();
		headers.add("Authorization", "Bearer "+accessToken);
		headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
		
		ResponseEntity<String> response = new RestTemplate().exchange(
				"https://openapi.naver.com/v1/nid/me",
				HttpMethod.POST, 
				new HttpEntity<String>(headers),
				String.class
				);
		System.out.println(response.getBody());
		return setUserInfo(new JsonParser().parse(response.getBody()));
	}

	@Override
	protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
		HashMap<String, String> userInfo = new HashMap<String, String>();
		userInfo.put("provider", "NAVER");
		userInfo.put("providerId", userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
		userInfo.put("username", "NAVER"+"_"+userInfoJson.getAsJsonObject().get("response").getAsJsonObject().get("id").getAsString());
		userInfo.put("password", "null");
		return userInfo;
	}

	@Override
	protected HashMap<String, String> setUserInfo(JsonElement userInfoJson) {
		HashMap<String, String> userInfo = new HashMap<String, String>();
		userInfo.put("provider", "KAKAO");
		userInfo.put("providerId", userInfoJson.getAsJsonObject().get("id").getAsString());
		userInfo.put("username", "KAKAO"+"_"+userInfoJson.getAsJsonObject().get("id").getAsString());
		userInfo.put("password", "null");
		return userInfo;
	}
	
	@Override
	protected ModelAndView setLogin(HashMap<String, String> userInfo) {
		Member checkMember = loginDAO.findUser(userInfo.get("username"));

		if(checkMember==null) {
		
			checkMember = Member.builder()
						.username(userInfo.get("username"))
						.password(bCryptPasswordEncoder.encode(userInfo.get("password")))
						.nickName("null")
						.email("null")
						.status("Y")
						.orange(0L)
						.role("ROLE_USER")
						.provider(userInfo.get("provider"))
						.providerId(userInfo.get("providerId"))
						.build();
		
			loginDAO.registerMember(checkMember);

		}
		
		Member member = loginDAO.findUser(checkMember.getUsername());

		ModelAndView mv = new ModelAndView();
		mv.addObject("username", member.getUsername());
		mv.addObject("password", member.getPassword());
		mv.setViewName("/login/social");
		return mv;
	}

}

두개의 소셜로그인을 공통 템플릿을 하나 만들어서 각각 구현했는데 확장성이나 유연성에서 훨씬 편한느낌이었다. 추후에 구글이나 페이스북 로그인을 추가한다면 해당 템플릿 틀에서 어렵지 않게 구현이 가능해졌다.

profile
에러가 세상에서 제일 좋아

0개의 댓글