[Spring] 카카오 로그인 Open API
안녕하세요 코북입니다. 지난번에 네아로 Open API를 사용해 네이버 아이디로 로그인 연동을 구현해봤는데요. 카카오 Open API도 같은 방식으로 구현이 돼서 기록하러 왔습니다. API의 흐름은 앞에서 언급했던 것과 동일하니 생략하도록 하겠습니다. 참고해주시면 감사하겠습니다.
작업 진행순서도 똑같습니다.
1. 카카오 Open API 이용신청
2. OAuth2.0 Java OpenSource Library 설정
3. Scribe Library를 이용하여 카카오 인증 버튼 적용
4. Controller
5. jsp
1. 카카오 Open API 이용신청
https://developers.kakao.com/docs/latest/ko/kakaologin/common
위 사이트에 들어가셔서 "내 애플리케이션 > 애플리케이션 추가하기"를 통해 애플리케이션을 추가해주세요. 추가 후 발행되는 REST API 키가 바로 네이버에서 사용했던 Client ID입니다. 다음은 "제품 설정 > 보안"에 들어가서 Client Secret을 발급받아야 합니다.
다음은 "제품설정 > 동의 항목"에 들어가서 Resource Owner가 Client에게 제공할 정보들을 체크해줍니다.
2. OAuth 2.0 Java OpenSource Library 설정
다음은 OAuth 2.0 프로토콜을 지원하는 Scribe OpenSource OAuth Java Library를 적용합니다. 나중에 Controller에서 Json 데이터 형식인 사용자 정보를 처리하기 위해 Json Parsing Library도 추가했습니다.
■ pom.xml에 dependency 추가
<!-- OAuth2.0 -->
<dependency>
<groupId>com.github.scribejava</groupId>
<artifactId>scribejava-core</artifactId>
<version>2.8.1</version>
</dependency>
<!-- 제이슨 파싱 -->
<dependency>
<groupId>com.googlecode.json-simple</groupId>
<artifactId>json-simple</artifactId>
<version>1.1.1</version>
</dependency>
■ Scribe library용 Kakao Login 구현체 추가
package city.turtle.mapper;
import com.github.scribejava.core.builder.api.DefaultApi20;
public class KakaoOAuthApi extends DefaultApi20 {
protected KakaoOAuthApi() {
}
private static class InstanceHolder {
private static final KakaoOAuthApi INSTANCE = new KakaoOAuthApi();
}
public static KakaoOAuthApi instance() {
return InstanceHolder.INSTANCE;
}
@Override
public String getAccessTokenEndpoint() {
return "https://kauth.kakao.com/oauth/token";
}
@Override
protected String getAuthorizationBaseUrl() {
return "https://kauth.kakao.com/oauth/authorize";
}
}
3. Scribe Library를 이용하여 카카오 인증 버튼 적용
카카오 로그인 연동 Business logic을 처리하기 위한 BO Class 생성하여, 인증 요청문을 구성해 줍니다.
■ KakaoLoginBO
package city.turtle.mapper;
import java.util.UUID;
import javax.servlet.http.HttpSession;
import org.springframework.util.StringUtils;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.model.OAuthRequest;
import com.github.scribejava.core.model.Response;
import com.github.scribejava.core.model.Verb;
import com.github.scribejava.core.oauth.OAuth20Service;
public class KakaoLoginBO {
// 카카오 로그인 정보
private final static String KAKAO_CLIENT_ID = "클라이언트 ID, REST API키";
private final static String KAKAO_CLIENT_SECRET = "클라이언트 시크릿";
private final static String KAKAO_REDIRECT_URI = "http://localhost:8081/web/callbackKakao.do"; //Redirect URL
private final static String SESSION_STATE = "kakao_oauth_state";
private final static String PROFILE_API_URL = "https://kapi.kakao.com/v2/user/me";
public String getAuthorizationUrl(HttpSession session) {
String state = generateRandomString();
setSession(session, state);
OAuth20Service oauthService = new ServiceBuilder()
.apiKey(KAKAO_CLIENT_ID)
.apiSecret(KAKAO_CLIENT_SECRET)
.callback(KAKAO_REDIRECT_URI)
.state(state).build(KakaoOAuthApi.instance());
return oauthService.getAuthorizationUrl();
}
public OAuth2AccessToken getAccessToken(HttpSession session, String code, String state) throws Exception {
String sessionState = getSession(session);
if (StringUtils.pathEquals(sessionState, state)) {
OAuth20Service oauthService = new ServiceBuilder()
.apiKey(KAKAO_CLIENT_ID)
.apiSecret(KAKAO_CLIENT_SECRET)
.callback(KAKAO_REDIRECT_URI)
.state(state).build(KakaoOAuthApi.instance());
OAuth2AccessToken accessToken = oauthService.getAccessToken(code);
return accessToken;
}
return null;
}
public String getUserProfile(OAuth2AccessToken oauthToken) throws Exception {
OAuth20Service oauthService = new ServiceBuilder()
.apiKey(KAKAO_CLIENT_ID)
.apiSecret(KAKAO_CLIENT_SECRET)
.callback(KAKAO_REDIRECT_URI)
.build(KakaoOAuthApi.instance());
OAuthRequest request = new OAuthRequest(Verb.GET, PROFILE_API_URL, oauthService);
oauthService.signRequest(oauthToken, request);
Response response = request.send();
return response.getBody();
}
private String generateRandomString() {
return UUID.randomUUID().toString();
}
private void setSession(HttpSession session, String state) {
session.setAttribute(SESSION_STATE, state);
}
private String getSession(HttpSession session) {
return (String) session.getAttribute(SESSION_STATE);
}
}
■ Controller Class에서 BO Class를 이용할 수 있도록 servlet-context.xml에 Bean으로 등록
<!-- KakaoLoginBO Class에 대한 Bean설정 추가 -->
<beans:bean id="kakaoLoginBO" class="city.turtle.mapper.KakaoLoginBO" />
4. Controller
이제 로그인 화면이 나타날 때 카카오 로그인 버튼에 카카오 로그인 인증 URL을 View로 보내주기 위해 Controller를 설정해줍니다. 기존에 controller에 있던 login 메소드는 사이트로 이동만을 나타내 주는 형식이었습니다.
@RequestMapping("/login.do")
public String login() {
return "login";
package city.turtle.web;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.google.connect.GoogleConnectionFactory;
import org.springframework.social.oauth2.GrantType;
import org.springframework.social.oauth2.OAuth2Operations;
import org.springframework.social.oauth2.OAuth2Parameters;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import city.turtle.mapper.KakaoLoginBO;
import city.turtle.mapper.MembersMapper;
import city.turtle.mapper.MembersVO;
import city.turtle.mapper.NaverLoginBO;
import com.github.scribejava.core.model.OAuth2AccessToken;
@Controller
public class CityTurtleController {
/* KakaoLogin */
@Autowired
private KakaoLoginBO kakaoLoginBO;
// 로그인페이지
//로그인 첫 화면 요청 메소드
@RequestMapping(value = "/login.do", method = { RequestMethod.GET, RequestMethod.POST })
public String login(Model model, HttpSession session) {
/* 네아로 인증 URL을 생성하기 위하여 naverLoginBO클래스의 getAuthorizationUrl메소드 호출 */
String naverAuthUrl = naverLoginBO.getAuthorizationUrl(session);
System.out.println("네이버:" + naverAuthUrl);
model.addAttribute("urlNaver", naverAuthUrl);
/* 카카오 URL */
String kakaoAuthUrl = kakaoLoginBO.getAuthorizationUrl(session);
System.out.println("카카오:" + kakaoAuthUrl);
model.addAttribute("urlKakao", kakaoAuthUrl);
/* 생성한 인증 URL을 View로 전달 */
return "login";
}
// 카카오 로그인 성공시 callback
@RequestMapping(value = "/callbackKakao.do", method = { RequestMethod.GET, RequestMethod.POST })
public String callbackKakao(Model model, @RequestParam String code, @RequestParam String state, HttpSession session)
throws Exception {
System.out.println("로그인 성공 callbackKako");
OAuth2AccessToken oauthToken;
oauthToken = kakaoLoginBO.getAccessToken(session, code, state);
// 로그인 사용자 정보를 읽어온다
apiResult = kakaoLoginBO.getUserProfile(oauthToken);
JSONParser jsonParser = new JSONParser();
JSONObject jsonObj;
jsonObj = (JSONObject) jsonParser.parse(apiResult);
JSONObject response_obj = (JSONObject) jsonObj.get("kakao_account");
JSONObject response_obj2 = (JSONObject) response_obj.get("profile");
// 프로필 조회
String email = (String) response_obj.get("email");
String name = (String) response_obj2.get("nickname");
// 세션에 사용자 정보 등록
// session.setAttribute("islogin_r", "Y");
session.setAttribute("signIn", apiResult);
session.setAttribute("email", email);
session.setAttribute("name", name);
return "redirect:/loginSuccess.do";
}
// 소셜 로그인 성공 페이지
@RequestMapping("/loginSuccess.do")
public String loginSuccess() {
return "loginSuccess";
}
callback호출 메소드에서 Json Parsing을 하기 위해 throws Exception으로 예외처리를 해줍니다. getAccessToken 메소드를 사용해 토큰을 가져와서 Kakao에 사용자 정보를 요청합니다. 받은 정보는 Json Parsing 처리하여 세션으로 로그인 성공 페이지에 넘겨줍니다. 카카오에서 보낸 Json 파일의 형식은
카카오 json
{"id":아이디,"connected_at":"2021-10-26T05:41:47Z","properties":{"nickname":"이름"}
,"kakao_account":{"profile_nickname_needs_agreement":false
,"profile":{"nickname":"이름"}
,"has_email":true
,"email_needs_agreement":false
,"is_email_valid":true
,"is_email_verified":true
,"email":"카카오계정(이메일)"
,"has_age_range":true
,"age_range_needs_agreement":false
,"age_range":"20~29"
,"has_birthday":true
,"birthday_needs_agreement":false
,"birthday":"생일"
,"birthday_type":"SOLAR"
,"has_gender":true
,"gender_needs_agreement":false
,"gender":"성별"}}
이었습니다. kakao_account value > profile > nickname의 value값이기 때문에 다음과 같이 두 번 get한 후에 값에 접근했습니다. apiResult는 메인 페이지에 로그인 session으로 보냈습니다. return값은 forward가 아닌 redirect 하도록 설정했습니다. forward 방식을 사용할 경우 현재 실행된 페이지와 forward에 의해 호출될 페이지는 request, response객체를 공유하게 됩니다. 사용자의 최초 요청정보가 다음 URL에서도 유효하기 때문에 사용자가 로그인 후 새로고침을 하면 동일한 요청이 여러 번 전달될 수 있고 이를 처리하는 과정에서 오류가 발생할 수 있습니다.
JSONObject response_obj = (JSONObject) jsonObj.get("kakao_account");
JSONObject response_obj2 = (JSONObject) response_obj.get("profile");
// 프로필 조회
String email = (String) response_obj.get("email");
String name = (String) response_obj2.get("nickname");
5. jsp
■ 로그인 페이지 jsp
<section class="bg-light">
<div class="container py-4">
<div class="row align-items-center justify-content-between">
<a class="navbar-brand h1 text-center" href="index.do">
<span class="text-dark h4">도시</span> <span class="text-primary h4">거북</span>
</a>
</div>
<c:if test="${signIn == null}">
<form action="${cpath}/signIn.do" method="post">
<div class="form-group">
<input type="text" class="form-control" name="mb_id" placeholder="아이디">
</div>
<div class="form-group">
<input type="password" class="form-control" name="mb_pwd" placeholder="비밀번호">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="flexCheckDefault">
<label class="form-check-label text-secondary" for="flexCheckDefault">
로그인 상태 유지
</label>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary btn-lg" type="submit">로그인</button>
</div>
</form>
<div class="otherButton text-center">
<span class="text-secondary">다른 계정으로 로그인</span>
<button type ="button" class = "btn" onclick="location.href='${urlNaver}'"><img src='./resources/img/logoNaver5.png'></button>
<button type ="button" class = "btn" onclick="location.href='${urlKakao}'"><img src='./resources/img/logoKakao5.png'></button>
<button type ="button" class = "btn"><img src='./resources/img/logoGoogle4.png'></button>
</div>
<div class="row">
<div class="col-lg-6 col-sm-12 text-lg-start text-center">
<button type="button" class="btn text-secondary" onclick="location.href='signUp.do'">회원가입</button>
</div>
<div class="col-lg-6 col-sm-12 text-lg-end text-center">
<button type="button" class="btn text-secondary">아이디/비밀번호 찾기</button>
</div>
</div>
</c:if>
</div>
</section>
■ 로그인 성공 페이지 jsp - EL
<section class="bg-light">
<div class="container py-4">
<div class="row align-items-center justify-content-between">
<a class="navbar-brand h1 text-center" href="index.do">
<span class="text-dark h4">도시</span> <span class="text-primary h4">거북</span>
</a>
</div>
<div>
<h1 class ="text-dark text-center">환영합니다!</h1>
<p class="text-center">
<span>${name}</span>님의 로그인 성공<br> 이메일 주소는 <strong>${email}</strong>입니다.
</p>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-primary btn-lg" onclick="location.href='index.do'">시작하기</button>
</div>
</div>
</section>
구현 화면
구현 화면입니다. 카카오 디벨로퍼에서는 카카오 디자인 가이드를 따르는 것을 권장합니다.
https://developers.kakao.com/docs/latest/ko/reference/design-guide
사용 기술 및 언어
Spring MVC, JAVA, Bootstrap, JavaScript, JQuery, JSP, EL
배운 점
네이버와 동일한 방식으로 카카오 로그인을 구현해봤습니다. 두 번째이다 보니 네이버 로그인 구현할 때 보다 능숙하게 구현할 수 있었던 것 같습니다. 특히 사용자의 정보를 불러오기 위해 Json Parsing을 사용한 점과 EL태그를 사용해 간편하게 화면을 구현한 점이 큰 차이였습니다. 완성 후 servlet-context 파일에서는 KakaoLoginBO를 읽지 못하는 오류가 발생했었는데 Bean 설정을 추가하여 오류를 해결했습니다. forward방식으로 로그인을 처리하여 발생한 오류를 redirect방식으로 처리해서 해결했습니다. 로그인은 시스템(session, DB)에 변화가 생기는 요청이기 때문에 redirect방식으로 처리하는 것이 바람직합니다. 반대로 시스템에 변화가 생기지 않는 단순 조회(리스트 보기, 검색)의 경우 forward방식으로 응답하는 것이 바람직합니다.
본 글은 아래 링크의 내용을 참고하여 학습한 내용을 나름대로 정리한 글임을 밝힙니다.
Kakao developer
개발가이드
https://dalili.tistory.com/171
json 파싱
https://calyfactory.github.io/%EC%A0%9C%EC%9D%B4%EC%8A%A8%ED%8C%8C%EC%8B%B1/
https://ktko.tistory.com/entry/JAVA%EC%97%90%EC%84%9C-JSON-%ED%8C%8C%EC%8B%B1%ED%95%98%EA%B8%B0
캐스팅
https://computer-science-student.tistory.com/335
예외처리
https://imasoftwareengineer.tistory.com/89
forwar vs redirect
https://doublesprogramming.tistory.com/63
https://yenbook.tistory.com/19?category=1035000https://doublesprogramming.tistory.com/63