소셜로그인은 앱 런칭에 있어서 필수적인 요소는 분명히 아니지만, 보안 및 편의성 등을 이유로, 거의 대부분의 프로젝트에서 소셜로그인을 구현하는 것으로 알고 있습니다. 소셜로그인에는 구글, 네이버, 카카오 등 다양한 방식이 존재하지만, 이번 포스팅에서는 카카오 소셜로그인을 사용해 볼 것입니다. 카카오 소셜로그인, 네이버 소셜로그인, 구글 소셜로그인은 이미 다른 포스팅에서 상세하게 다룬 적 있기 때문에, API 테스트 또는 코드 설명은 아래의 링크를 참조해주세요.
>> 카카오 소셜로그인
>> 구글 소셜로그인
>> 네이버 소셜로그인
① 가장 먼저 카카오 SDK Repository 관련 설정을 진행해야 한다. settings.gradle의 repository를 아래와 같이 수정한다.
repositories {
google()
mavenCentral()
maven { url = uri("https://devrepo.kakao.com/nexus/content/groups/public/") }
}
② Module 수준의 build.gradle 파일에 아래의 의존성을 추가한다.
implementation("com.kakao.sdk:v2-user:2.10.0")
③ 위의 ">> 카카오 소셜로그인" 링크에서 알려주는 방법을 따라 아래와 같이 본인의 앱을 등록한다.
④ 앱 설정 > 플랫폼 > Android 플랫폼 등록 버튼을 클릭한다.
⑤ Android 플랫폼 등록을 위해선 키 해시 값이 필요하다. MainActivity에 아래의 내용을 입력하자.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val keyHash = Utility.getKeyHash(this)
Log.d("Hash", keyHash)
}
}
⑥ 코드를 실행시켜 로그에 찍힌 키 해시 값을 복사해 Android 플랫폼 등록 폼에 붙여 넣는다.
⑦ GlobalApplication이라는 이름의 kotlin class를 생성한다.
import android.app.Application
import com.kakao.sdk.common.KakaoSdk
class GlobalApplication : Application() {
override fun onCreate() {
super.onCreate()
KakaoSdk.init(this, "{네이티브 앱 키}")
}
}
⑧ Manifest 파일의 application 태그의 속성에 아래의 내용을 추가한다.
android:name=".GlobalApplication"
⑨ 계속해서 Manifest 파일에 아래의 Activity를 추가한다.
<activity
android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="oauth"
android:scheme="kakao{네이티브 앱 키}" />
</intent-filter>
</activity>
⑩ 아래의 링크에서 카카오 소셜 로그인에 사용할 버튼의 이미지를 다운로드 받을 수 있다. png 파일을 다운로드 받아 kakao_login이라는 이름으로 drawable 디렉토리 하위에 추가한다.
>> 로그인 버튼 이미지 다운로드
⑪ activity_intro.xml에 아래의 ImageView 태그를 추가한다.
<ImageButton
android:id="@+id/kakao_login"
android:layout_width="match_parent"
android:layout_height="50dp"
android:scaleType="fitXY"
android:src="@drawable/kakao_login"
android:layout_margin="15dp"
android:background="@android:color/transparent" />
⑫ IntroActivity를 아래와 같이 수정한다.
class IntroActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_intro)
val joinBtn = findViewById<Button>(R.id.join)
joinBtn.setOnClickListener {
val intent = Intent(this, JoinActivity::class.java)
startActivity(intent)
}
val loginBtn = findViewById<Button>(R.id.login)
loginBtn.setOnClickListener {
val intent = Intent(this, LoginActivity::class.java)
startActivity(intent)
}
val callback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
if (error != null) {
when {
error.toString() == AuthErrorCause.AccessDenied.toString() -> {
Toast.makeText(this, "접근이 거부 됨(동의 취소)", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidClient.toString() -> {
Toast.makeText(this, "유효하지 않은 앱", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidGrant.toString() -> {
Toast.makeText(this, "인증 수단이 유효하지 않아 인증할 수 없는 상태", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidRequest.toString() -> {
Toast.makeText(this, "요청 파라미터 오류", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidScope.toString() -> {
Toast.makeText(this, "유효하지 않은 scope ID", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.Misconfigured.toString() -> {
Toast.makeText(this, "설정이 올바르지 않음(android key hash)", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.ServerError.toString() -> {
Toast.makeText(this, "서버 내부 에러", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.Unauthorized.toString() -> {
Toast.makeText(this, "앱이 요청 권한이 없음", Toast.LENGTH_SHORT).show()
}
else -> { // Unknown
Toast.makeText(this, "기타 에러", Toast.LENGTH_SHORT).show()
}
}
}
else if (token != null) {
Log.d("token", token.accessToken)
Toast.makeText(this, "로그인에 성공하였습니다.", Toast.LENGTH_SHORT).show()
val intent = Intent(this, MainActivity::class.java)
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP))
finish()
}
}
val kakaoLoginBtn = findViewById<ImageView>(R.id.kakao_login)
kakaoLoginBtn.setOnClickListener {
if(UserApiClient.instance.isKakaoTalkLoginAvailable(this)){
UserApiClient.instance.loginWithKakaoTalk(this, callback = callback)
}else{
UserApiClient.instance.loginWithKakaoAccount(this, callback = callback)
}
}
}
}
⑬ 콘솔 로그에서 access token이 잘 출력되는지 확인한다.
이제 본격적으로 카카오 소셜로그인 기능을 구현해보도록 하겠다. 위에서 받은 access token을 이용하여 유저의 정보를 가져온 후, DB와 파이어베이스에 해당 정보를 저장해야 한다.
① jwt 패키지 하위로, kakao 패키지를 추가한다. 그리고 kakao 패키지 하위로, KakaoController, KakaoService 클래스를 추가하고 dto > GetKakaoUserRes, PostKakaoLoginRes, PostKakaoUserReq를 추가한다.
② KakaoController에 아래의 내용을 입력한다.
@RestController
@RequiredArgsConstructor
public class KakaoController {
private final KakaoService kakaoService;
private final JwtService jwtService;
/**
* 카카오 소셜로그인
*/
@ResponseBody
@PostMapping("/oauth/kakao")
public BaseResponse<PostKakaoLoginRes> kakaoCallback(@RequestParam("token") String accessToken) {
try {
return new BaseResponse<>(kakaoService.kakaoCallBack(accessToken));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
}
③ KakaoService에 아래의 내용을 입력한다.
@Service
@RequiredArgsConstructor
public class KakaoService {
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
private final UtilService utilService;
/**
* 카카오 콜백 메서드
*/
public PostKakaoLoginRes kakaoCallBack(String accessToken) throws BaseException {
GetKakaoUserRes getKakaoUserRes = getUserInfo(accessToken);
String email = getKakaoUserRes.getEmail();
String nickName = getKakaoUserRes.getNickName();
Optional<User> findUser = userRepository.findByEmail(email);
JwtResponseDto.TokenInfo tokenInfo;
if (!findUser.isPresent()) { // 회원가입인 경우
User kakaoUser = new User();
kakaoUser.createUser(nickName, email, null, null);
userRepository.save(kakaoUser);
tokenInfo = jwtProvider.generateToken(kakaoUser.getId());
return new PostKakaoLoginRes(kakaoUser.getId(), kakaoUser.getEmail(), tokenInfo.getAccessToken(), tokenInfo.getRefreshToken());
}
else { // 기존 회원이 로그인하는 경우
User user = findUser.get();
tokenInfo = jwtProvider.generateToken(user.getId());
return new PostKakaoLoginRes(user.getId(), user.getEmail(), tokenInfo.getAccessToken(), tokenInfo.getRefreshToken());
}
}
/**
* 카카오 유저의 정보 가져오기
*/
public GetKakaoUserRes getUserInfo(String accessToken) throws BaseException{
// HttpHeader 생성
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add("Authorization", "Bearer " + accessToken);
httpHeaders.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HttpHeader와 HttpBody를 하나의 객체에 담기(body 정보는 생략 가능)
HttpEntity<String> requestEntity = new HttpEntity<>(httpHeaders);
// RestTemplate를 이용하여 HTTP 요청 처리
RestTemplate restTemplate = new RestTemplate();
// Http 요청을 GET 방식으로 실행하여 멤버 정보를 가져옴
ResponseEntity<String> responseEntity = restTemplate.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.GET,
requestEntity,
String.class
);
// 카카오 인증 서버가 반환한 사용자 정보
String userInfo = responseEntity.getBody();
// JSON 데이터에서 필요한 정보 추출
Gson gsonObj = new Gson();
Map<?, ?> data = gsonObj.fromJson(userInfo, Map.class);
// 이메일 동의 여부 확인
boolean emailAgreement = (boolean) ((Map<?, ?>) (data.get("kakao_account"))).get("email_needs_agreement");
String email;
if (emailAgreement) { // 사용자가 이메일 동의를 하지 않은 경우
email = ""; // 대체값 설정
} else { // 사용자가 이메일 제공에 동의한 경우
// 이메일 정보 가져오기
email = (String) ((Map<?, ?>) (data.get("kakao_account"))).get("email");
}
// 닉네임 동의 여부 확인
boolean nickNameAgreement = (boolean) ((Map<?, ?>) (data.get("kakao_account"))).get("profile_nickname_needs_agreement");
String nickName;
if (nickNameAgreement) { // 사용자가 닉네임 동의를 하지 않은 경우
nickName = ""; // 대체값 설정
} else { // 사용자가 닉네임 제공에 동의한 경우
// 닉네임 정보 가져오기
nickName = (String) ((Map<?, ?>) ((Map<?, ?>) data.get("properties"))).get("nickname");
}
return new GetKakaoUserRes(email, nickName);
}
}
④ GetKakaoUserRes에 아래의 내용을 입력한다.
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class GetKakaoUserRes {
private String email;
private String nickName;
}
⑤ build.gradle에 gson 관련 의존성을 추가한다.
implementation 'com.google.code.gson:gson:2.8.8'
⑥ User 엔티티의 password 필드 값을 nullable로 변경한다.
@Column(nullable = true)
private String password;
⑦ 2번에서 콘솔 로그에 찍었던 access token 값을 가져와서 postman에서 테스트해보자. 로그인이 잘 처리되고, 유저의 정보도 DB에 잘 저장될 것이다.
⑧ 이제 프론트엔드로부터 uid와 device token 값을 받아 해당 유저의 필드 값으로 set하는 API를 만들어야 한다.
⑨ KakaoController에 아래의 API를 추가한다.
/**
* 카카오 소셜로그인 유저의 uid와 device token 값을 set
*/
@PostMapping("/oauth/device-token")
public BaseResponse<String> saveUidAndToken(@RequestBody PostKakaoUserReq postKakaoUserReq) {
try {
Long userId = jwtService.getUserIdx();
return new BaseResponse<>(kakaoService.saveUidAndToken(userId, postKakaoUserReq));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
⑩ KakaoService에 아래의 메서드를 추가한다.
/**
* 카카오 소셜로그인 유저의 uid와 device token 값을 set
*/
public String saveUidAndToken(Long userId, PostKakaoUserReq postKakaoUserReq) throws BaseException{
User user = utilService.findByUserIdWithValidation(userId);
user.setUid(postKakaoUserReq.getUid());
user.setDeviceToken(postKakaoUserReq.getDeviceToken());
userRepository.save(user);
return "UID와 디바이스 토큰이 저장되었습니다.";
}
⑪ PostKakaoUserReq에 아래의 내용을 입력한다.
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class PostKakaoUserReq {
String uid;
String deviceToken;
}
⑫ PostKakaoLoginRes에 아래의 내용을 입력한다.
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class PostKakaoLoginRes {
private Long userId;
private String email;
private String accessToken;
private String refreshToken;
}
① api > dto 패키지 하위로, PostKakaoUserReq라는 data class를 추가한다.
data class PostKakaoUserReq(
@SerializedName("uid")
val uid : String,
@SerializedName("deviceToken")
val deviceToken : String,
)
② api > dto 패키지 하위로, PostKakaoLoginRes도 추가한다.
data class PostKakaoLoginRes (
@SerializedName("userId")
val userId : Long,
@SerializedName("email")
val email : String,
@SerializedName("accessToken")
val accessToken : String,
@SerializedName("refreshToken")
val refreshToken : String
)
③ api 패키지 하위로, KakaoAPi 인터페이스를 추가한다.
interface KakaoApi {
@POST("/oauth/kakao")
suspend fun kakaoCallback(
@Query("token") accessToken : String // 카카오 서버에서 보내준 access token으로 인증 토큰이다.
) : BaseResponse<PostKakaoLoginRes>
@POST("/oauth/device-token")
suspend fun saveUidAndToken(
@Header("Authorization") accessToken : String,
@Body postKakapUserReq: PostKakaoUserReq
): BaseResponse<String>
}
④ RetrofitInstance에 아래의 내용을 추가한다.
val kakaoApi = retrofit.create(KakaoApi::class.java)
⑤ IntroActivity를 아래와 같이 수정한다.
class IntroActivity : AppCompatActivity() {
private lateinit var auth: FirebaseAuth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_intro)
auth = Firebase.auth
val joinBtn = findViewById<Button>(R.id.join)
joinBtn.setOnClickListener {
val intent = Intent(this, JoinActivity::class.java)
startActivity(intent)
}
val loginBtn = findViewById<Button>(R.id.login)
loginBtn.setOnClickListener {
val intent = Intent(this, LoginActivity::class.java)
startActivity(intent)
}
val callback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
if (error != null) {
when {
error.toString() == AuthErrorCause.AccessDenied.toString() -> {
Toast.makeText(this, "접근이 거부 됨(동의 취소)", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidClient.toString() -> {
Toast.makeText(this, "유효하지 않은 앱", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidGrant.toString() -> {
Toast.makeText(this, "인증 수단이 유효하지 않아 인증할 수 없는 상태", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidRequest.toString() -> {
Toast.makeText(this, "요청 파라미터 오류", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.InvalidScope.toString() -> {
Toast.makeText(this, "유효하지 않은 scope ID", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.Misconfigured.toString() -> {
Toast.makeText(this, "설정이 올바르지 않음(android key hash)", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.ServerError.toString() -> {
Toast.makeText(this, "서버 내부 에러", Toast.LENGTH_SHORT).show()
}
error.toString() == AuthErrorCause.Unauthorized.toString() -> {
Toast.makeText(this, "앱이 요청 권한이 없음", Toast.LENGTH_SHORT).show()
}
else -> { // Unknown
Toast.makeText(this, "기타 에러", Toast.LENGTH_SHORT).show()
}
}
}
else if (token != null) {
Log.d("accessToken", token.accessToken)
CoroutineScope(Dispatchers.IO).launch {
val response = kakaoCallback(token.accessToken)
Log.d("IntroActivity", response.toString())
if (response.isSuccess) {
Log.d("email", response.result!!.email)
if(FirebaseAuthUtils.getUid() == null) {
auth.createUserWithEmailAndPassword(response.result!!.email, "abc123")
}
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("MyToken", "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
val uid = FirebaseAuthUtils.getUid()
val deviceToken = task.result
val userInfo = UserInfo(uid, response.result?.userId,
deviceToken, response.result?.accessToken, response.result?.refreshToken)
Log.d("userInfo", userInfo.toString())
FirebaseRef.userInfo.child(uid).setValue(userInfo)
CoroutineScope(Dispatchers.IO).launch {
val postKakaoUserReq = PostKakaoUserReq(uid, deviceToken)
val saveRes = saveUidAndToken(response.result?.accessToken!!, postKakaoUserReq)
Log.d("UidToken", saveRes.toString())
if (saveRes.isSuccess) {
Log.d("UidToken", "UID와 디바이스 토큰 저장 완료")
} else {
Log.d("UidToken", "UID와 디바이스 토큰 저장 실패")
}
}
val intent = Intent(this@IntroActivity, MainActivity::class.java)
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP))
finish()
})
Log.d("IntroActivity", "로그인 완료")
} else {
// 로그인 실패 처리
Log.d("IntroActivity", "로그인 실패")
val message = response.message
Log.d("IntroActivity", message)
withContext(Dispatchers.Main) {
Toast.makeText(this@IntroActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
}
}
val kakaoLoginBtn = findViewById<ImageView>(R.id.kakao_login)
kakaoLoginBtn.setOnClickListener {
if (UserApiClient.instance.isKakaoTalkLoginAvailable(this)){
UserApiClient.instance.loginWithKakaoTalk(this, callback = callback)
} else {
UserApiClient.instance.loginWithKakaoAccount(this, callback = callback)
}
}
}
private suspend fun kakaoCallback(accessToken: String): BaseResponse<PostKakaoLoginRes> {
return RetrofitInstance.kakaoApi.kakaoCallback(accessToken)
}
private suspend fun saveUidAndToken(accessToken: String, postKakaoUserReq: PostKakaoUserReq): BaseResponse<String> {
return RetrofitInstance.kakaoApi.saveUidAndToken(accessToken, postKakaoUserReq)
}
}
이제 코드를 실행시켜보자. (SplashActivity의 자동 로그인 기능을 잠시 비활성화해둔 후 테스트해야 한다.) RDS Database와 Firebase Authentication, Realtime Database에 유저 정보가 모두 업로드 되어야 한다.