심화 - 카카오 소셜로그인

변현섭·2023년 9월 17일
0

소셜로그인은 앱 런칭에 있어서 필수적인 요소는 분명히 아니지만, 보안 및 편의성 등을 이유로, 거의 대부분의 프로젝트에서 소셜로그인을 구현하는 것으로 알고 있습니다. 소셜로그인에는 구글, 네이버, 카카오 등 다양한 방식이 존재하지만, 이번 포스팅에서는 카카오 소셜로그인을 사용해 볼 것입니다. 카카오 소셜로그인, 네이버 소셜로그인, 구글 소셜로그인은 이미 다른 포스팅에서 상세하게 다룬 적 있기 때문에, API 테스트 또는 코드 설명은 아래의 링크를 참조해주세요.

>> 카카오 소셜로그인
>> 구글 소셜로그인
>> 네이버 소셜로그인

2. 카카오 소셜로그인 설정

① 가장 먼저 카카오 SDK Repository 관련 설정을 진행해야 한다. settings.gradle의 repository를 아래와 같이 수정한다.

  • build.gradle 파일을 통해서 sdk를 다운로드할 수 있게 된다.
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를 추가한다.

  • kakao{네이티브 앱 키} 부분에는 본인의 네이티브 앱 키를 넣되, kakao를 prefix로 붙여야 한다는 뜻이다. 이 때, kakao와 네이티브 앱 키를 띄어쓰지 않는다.
<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이 잘 출력되는지 확인한다.

3. 카카오 소셜로그인 구현

이제 본격적으로 카카오 소셜로그인 기능을 구현해보도록 하겠다. 위에서 받은 access token을 이용하여 유저의 정보를 가져온 후, DB와 파이어베이스에 해당 정보를 저장해야 한다.

1) 백엔드

① jwt 패키지 하위로, kakao 패키지를 추가한다. 그리고 kakao 패키지 하위로, KakaoController, KakaoService 클래스를 추가하고 dto > GetKakaoUserRes, PostKakaoLoginRes, PostKakaoUserReq를 추가한다.

② KakaoController에 아래의 내용을 입력한다.

  • 소셜 로그인 유저나 일반 로그인 유저나 같은 로그아웃 API를 이용할 것이기 때문에 별도의 소셜 로그아웃은 구현하지 않을 예정이다.
@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에 아래의 내용을 입력한다.

  • 프로필 사진 URL도 받고 싶은 경우는 알맞게 메서드를 수정하자.
  • 여기서는 카카오 프로필 사진은 받아오지 않기로 한다.
@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로 변경한다.

  • DB에 저장되어 있는 이전의 유저 데이터를 다 삭제해야 에러가 안 날 것이다.
  • yml 파일의 ddl-auto를 create로 설정한 후 코드를 실행시키자.
@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에 아래의 내용을 입력한다.

  • 소셜로그인 유저를 파이어베이스 Authentication에 등록하기 위해 email을 반환하도록 만들자.
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class PostKakaoLoginRes {
    private Long userId;
    private String email;
    private String accessToken;
    private String refreshToken;
}

2) 프론트엔드

① 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를 아래와 같이 수정한다.

  • 참고로, 소셜 로그인 유저의 계정을 생성할 때 "abc123"이라는 패스워드를 사용하고 있는데, 이는 단지 uid를 생성하기 위해 임의로 넣어준 값일 뿐이다.
  • 즉, abc123이 해당 유저의 비밀번호인 것은 아니고, 일종의 쓰레기 값(dummy data)인 것이다. DB에 저장되는 실제 소셜 로그인 유저의 비밀번호는 null이다.
  • access token을 메서드에 넘길 때, getAccessToken 메서드를 사용하지 말고, 로그인 API의 응답으로 받은 accessToken을 바로 사용하는 편이 더 좋다.
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에 유저 정보가 모두 업로드 되어야 한다.


profile
Java Spring, Android Kotlin, Node.js, ML/DL 개발을 공부하는 인하대학교 정보통신공학과 학생입니다.

0개의 댓글