카카오 로그인 및 메시지 api를 이용한 메시지 전송
https://teamdev.shop/kakao <- 이곳에 접속하면 자신에게 카카오톡 메시지를 보내볼 수 있습니다.
어느덧 첫번째 팀 프로젝트를 시작하게 되었다.
스택오버플로우처럼 개발에 관한 질의응답 게시판을 만들기로 했다.
사실 예전부터 구현해보고 싶었던 기능이 있었는데
바로 카카오톡으로 메시지 및 알림을 보내는 것이다..!
키워드에 등록한 질문이나 답변이 달리는 경우 카카오톡 메시지, 알림을 보내면
사용자 경험이 극대화 될 것 같아서 이번 프로젝트에 적용해보기 위해
카카오 api 문서를 참고해서 차근차근 구현해 보았다.
메시지를 전송하려면 사용자의 토큰이 필요하고
토큰을 발급 받으려면 카카오 로그인 api를 이용해서 얻은
사용자 인증 코드가 필요하기 때문에
카카오톡 로그인까지 학습했다.
카카오는 api가 다양하고 문서도 잘 정리되어 있어 사용하기 편했다.
역시 국민서비스다..!
카카오 메시지 api로 메시지를 보내는 과정은 다음과 같다.
문서엔 이렇게 나와있는데
이것은 친구 목록을 이용해
다른사람에게 메시지를 보내는 과정이다.
직접 구현을 해보니 http 통신을 이용한 REST API에서 실제 메시지를 보내는 과정은 다음과 같다.
0. 서버는 카카오 서비스를 이용하기 위해 앱 등록을 하고 api 키를 발급받아야 한다.
1. 사용자가 메세지 전송 기능을 사용하려고 하면 서버는 서버의 카카오 앱 api 키가 포함되어있는 카카오 로그인 url를 응답한다.
2. 사용자가 로그인 url에 접속하면 카카오 로그인 페이지가 나온다.
3. 인증이 완료되고 적절한 권한을 허락하면 사용자 인증 코드가 앱에서 미리 설정한 url 뒤에 쿼리스트링으로 붙어 리다이렉트된다.
4. 서버에선 이 코드를 받아서 카카오 토큰 api를 호출하여 해당 사용자의 토큰을 발급 받는다.
5. 발급 받은 토큰으로 카카오 메시지 api를 호출하여 메시지를 전송한다.
뜻하지 않게 유효하지 않은 요청도 보내보고 응답을 받아보니
아마 카카오 api 내부적으로는
사용자가 로그인 할때는 카카오 로그인 url(에 포함된 리다이렉트 주소)과 앱에 등록된 리다이렉트 주소를 비교하고
서버가 api를 호출할때는 전송된 주소와 api키를 사용자가 로그인을 할 때의 정보와 비교하여
유효한 요청인지 검증하는 것으로 추측된다.
토큰을 별도로 저장하거나 갱신하는 것에 대해서는 구현하지 않은 상태다.
그리고 친구 목록에 접근하기 위해선 사용 권한을 신청해야 하기 때문에
지금은 사용자 본인에게만 메시지를 전송할 수 있다.
즉, 단순히 서비스를 이용하려고 할 때마다
토큰을 발급받고 '나에게 보내기' 기능만을 제공한다.
토큰 관리와 갱신에 대해서는 프로젝트에 적용할 때 간단히 설명하려고 한다.
서버의 주소와 api key는 한번만 수정해도 작동할 수 있도록
그리고 코드상에서 노출되지 않도록 환경 변수로 선언했다.
AWS를 이용한 자동 배포도 미리 세팅해 놓았는데
자동 배포 도와주는 CodeDeploy는 기본적으로 bashrc를 사용하지 않기 때문에
EC2에서 환경변수를 선언해놨더라도 서버 구동에 실패한다.
CodeDeploy가 환경변수를 사용하게 하는 방법은 다른 글에서 설명하려고 한다.
서버가 클라이언트로서 다른 api 서버에 요청을 보내기 위해
Spring Webflux 라이브러리의 WebClient를 사용했다.
구현한 코드는 다음과 같다.
Controller
package com.server.kakao.controller;
import java.io.IOException;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import com.server.kakao.auth.Token;
import com.server.kakao.service.KakaoService;
@RestController
@RequestMapping("/kakao")
public class KakaoController {
@Value("${kakao.redirect-url}")
private String redirecUrl;
private String accessToken;
private String refreshToken;
private Token tokens;
private KakaoService kakaoService;
public KakaoController(KakaoService kakaoService) {
this.kakaoService = kakaoService;
}
@GetMapping
public Object getKakao(@RequestParam(value = "code", required = false) String code) {
// 토큰 갱신 작업 필요
if (code == null) {
return getMainPage(redirecUrl);
}
try {
tokens = kakaoService.getTokens(code, tokens);
} catch (Exception exception) {
return "토큰 발급에 실패했습니다. 오류 제보 부탁드립니다.";
}
String response = "토큰이 성공적으로 발급 되었습니다.\\n"
+ "버튼을 눌러 메시지를 보내보세요.(버튼 미구현)\\n\\n"
+ redirecUrl + "/kakao/{message}\\n"
+ "{message} 대신 원하는 메시지를 입력하면 전송됩니다.";
accessToken = tokens.getAccess_token();
refreshToken = tokens.getRefresh_token();
response = kakaoService.sendMessage(accessToken, response);
return response.replace("\\n", "<br />");
}
@GetMapping("/{message}")
// 나중에 입력창을 구현하고 Post요청으로 메시지 정보를 받으려고 한다.
public String postMessage(@PathVariable("message") String message,
HttpServletResponse response) throws IOException {
if (tokens == null) {
response.sendRedirect(redirecUrl + "/kakao");
return "인증 필요";
}
return kakaoService.sendMessage(accessToken, message);
}
public ModelAndView getMainPage(String redirectUrl) {
boolean isLocal = redirecUrl.charAt(7) == 'l';
if (isLocal) {
return new ModelAndView("local.html");
}
return new ModelAndView("kakao.html");
}
}
Service
package com.server.kakao.service;
import org.springframework.stereotype.Service;
import com.server.kakao.auth.KakaoAuth;
import com.server.kakao.auth.Token;
@Service
public class KakaoService {
private KakaoAuth kakaoAuth;
public KakaoService(KakaoAuth kakaoAuth) {
this.kakaoAuth = kakaoAuth;
}
public String sendMessage(String accessToken, String message) {
try {
kakaoAuth.sendMessage(accessToken, message);
} catch (Exception e) {
return "메시지 전송 실패. 오류 제보 부탁드립니다.";
}
return "메시지 전송 성공: " + message;
}
public Token getTokens(String code, Token tokens) {
tokens = kakaoAuth.requestTokens(code);
// tokens = kakaoAuth.refreshTokens();
return tokens;
}
public boolean isValid(Token tokens) {
return true;
}
}
Auth
인증과 요청 전송은 이 클래스에서 담당한다.
package com.server.kakao.auth;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
@Component
public class KakaoAuth {
@Value("${kakao.redirect-url}")
private String redirecUrl;
@Value("${kakao.api-key}")
private String apiKey;
private String tokenApiUrl = "https://kauth.kakao.com/oauth/token";
private String messageApiUrl = "https://kapi.kakao.com/v2/api/talk/memo/default/send";
public Token requestTokens(String code) {
Token tokens = WebClient.create(tokenApiUrl)
.post()
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters
.fromFormData("grant_type", "authorization_code")
.with("client_id", apiKey)
.with("redirect_url", redirecUrl)
.with("code", code))
.retrieve()
.bodyToMono(Token.class)
.block();
return tokens;
}
public Token refreshTokens() {
return null;
}
public String sendMessage(String accessToken, String message) {
String body = "{\"object_type\": \"text\", \"text\": \"" + message + "\", \"link\": {}}";
String result = WebClient.create(messageApiUrl)
.post()
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.header("Authorization", "Bearer " + accessToken)
.body(BodyInserters
.fromFormData("template_object", body))
.retrieve()
.bodyToMono(String.class)
.block();
return message;
}
public WebClient getClient(String url) {
// return WebClient.create(url).get();
return null;
}
public WebClient postClient() {
return null;
}
}
Token
각 필드는 json 형태로 응답 받은 토큰의 속성을 의미한다.
WebClient를 이용해서 응답을 곧바로 객체로 변환하니
json에서 직접 정보를 추출하는 과정이 생략되고
엔티티로서 관리하기도 편해졌다.
package com.server.kakao.auth;
import java.util.Date;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Token {
private String access_token;
private String refresh_token;
private String expires_in;
private String refresh_token_expires_in;
private Date accessExpiration;
private Date refreshExpiration;
}
이렇게 몇번의 api 호출만으로
사용자 인증을 위한 토큰 발급과
메시지 전송까지 구현 할 수 있었다.
다른 사람에게 메시지를 보내는 방법도 있는데 사용 권한 신청을 해야 하고
플러스 친구를 이용하는 방법은 사업자 번호가 필요하기 때문에
미리 준비해야 한다.