TIL

NEXTIL 프로젝트 kakao 로그인 구현

개발 일지 2025. 2. 18. 22:47

 

간단한 프로젝트로 진행할 것이기 때문에 소셜로그인을 이용해서 간단한 회원가입, 로그인이 가능하게 설계할 것이다.

 

NEXTIL 앱 설정

 

사이트 도메인 설정 (개발 단계에서는 localhost 설정)

 

리다이렉트 URI 설정

 

동의항목: 닉네임 설정

간단한 프로젝트이기때문에 닉네임만 받고 동일이름에 대한 사용자에 대해서는 닉네임+userId 등으로 설정하는 방향도 고려 중이다.

 

 

application.yml 설정

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: 비밀!
            clientSecret: 비밀!
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/kakao"
            scope: profile_nickname
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

 

카카오 소셜로그인 설정

package hjp.nextil.domain.member.controller

import hjp.nextil.domain.member.entity.MemberEntity
import hjp.nextil.domain.member.repository.MemberRepository
import hjp.nextil.security.jwt.JwtTokenManager
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.*
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.bind.annotation.GetMapping
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.client.RestTemplate
import org.springframework.web.client.exchange
import org.springframework.web.util.UriComponentsBuilder
import java.util.*

@RestController
@RequestMapping("/api/auth")
class OAuth2LoginController(
    private val jwtTokenManager: JwtTokenManager,
    private val memberRepository: MemberRepository,
    @Value("\${spring.security.oauth2.client.registration.kakao.client-id}") private val clientId: String,
    @Value("\${spring.security.oauth2.client.registration.kakao.client-secret}") private val clientSecret: String,
    @Value("\${spring.security.oauth2.client.registration.kakao.redirect-uri}") private val redirectUri: String,
    @Value("\${spring.security.oauth2.client.provider.kakao.token-uri}") private val tokenUri: String,
    @Value("\${spring.security.oauth2.client.provider.kakao.user-info-uri}") private val userInfoUri: String
) {

    @GetMapping("/kakao/login")
    fun kakaoLogin(): String {
        val kakaoAuthUrl = UriComponentsBuilder.fromHttpUrl("https://kauth.kakao.com/oauth/authorize")
            .queryParam("client_id", clientId)
            .queryParam("redirect_uri", redirectUri)
            .queryParam("response_type", "code")
            .toUriString()
        return kakaoAuthUrl
    }

    @GetMapping("/kakao/callback")
    fun kakaoCallback(@RequestParam("code") code: String): Map<String, String> {
        val restTemplate = RestTemplate()

        val tokenHeaders = HttpHeaders().apply {
            contentType = MediaType.APPLICATION_FORM_URLENCODED
        }

        val tokenBody = LinkedMultiValueMap<String, String>().apply {
            add("grant_type", "authorization_code")
            add("client_id", clientId)
            add("client_secret", clientSecret)
            add("redirect_uri", redirectUri)
            add("code", code)
        }

        val tokenRequest = HttpEntity(tokenBody, tokenHeaders)
        val tokenResponse = restTemplate.exchange(tokenUri, HttpMethod.POST, tokenRequest, Map::class.java).body
        val accessToken = tokenResponse?.get("access_token") as? String ?: throw RuntimeException("Access token not found")

        val userHeaders = HttpHeaders().apply {
            add("Authorization", "Bearer $accessToken")
            contentType = MediaType.APPLICATION_JSON
        }

        val userRequest = HttpEntity(null, userHeaders)
        val userResponse = restTemplate.exchange(userInfoUri, HttpMethod.GET, userRequest, Map::class.java).body

        val kakaoAccount = userResponse?.get("kakao_account") as Map<*, *>
        val profile = kakaoAccount["profile"] as Map<*, *>
        val nickname = profile["nickname"] as? String ?: "unknown"

        val existingUser = memberRepository.findByUserName(nickname)
        var userId: Long? = existingUser?.id

        if (existingUser == null) {
            val newUser = memberRepository.save(
                MemberEntity(
                    userName = nickname,
                    password = UUID.randomUUID().toString(),
                )
            )
            userId = newUser.id
        }

        val token = jwtTokenManager.generateTokenResponse(memberId = userId!!)

        return mapOf("token" to token)
    }
}

 

코드 해석 및 흐름 

@GetMapping("/kakao/login")
fun kakaoLogin(): String {
    val kakaoAuthUrl = UriComponentsBuilder.fromHttpUrl("https://kauth.kakao.com/oauth/authorize")
        .queryParam("client_id", clientId)
        .queryParam("redirect_uri", redirectUri)
        .queryParam("response_type", "code")
        .toUriString()
    return kakaoAuthUrl
}
클라이언트가 GET /api/auth/kakao/login 요청을 보내면 kakaoLogin() 메서드가 카카오 로그인 URL을 생성하여 응답

 

 

카카오가 Authorization code를 포함한  요청을 보냄

http://localhost:8080/api/auth/kakao/callback?code=AUTHORIZATION_CODE

 

/api/auth/kakao/callback 엔드포인트에서 Authorization code를 사용해서 AccessToken을 요청

@GetMapping("/kakao/callback")
fun kakaoCallback(@RequestParam("code") code: String): Map<String, String> {

 

해당 code를 가지고 카카오 토큰 발급 API (https://kauth.kakao.com/oauth/token)에 요청 

val tokenHeaders = HttpHeaders().apply {
    contentType = MediaType.APPLICATION_FORM_URLENCODED
}

val tokenBody = LinkedMultiValueMap<String, String>().apply {
    add("grant_type", "authorization_code")
    add("client_id", clientId)
    add("client_secret", clientSecret)
    add("redirect_uri", redirectUri)
    add("code", code)
}

val tokenRequest = HttpEntity(tokenBody, tokenHeaders)
val tokenResponse = restTemplate.exchange(tokenUri, HttpMethod.POST, tokenRequest, Map::class.java).body
val accessToken = tokenResponse?.get("access_token") as? String ?: throw RuntimeException("Access token not found")

카카오 서버에서 accessToken을 받음

 

받은 accessToken을 Authorization 헤더에 담아서 사용자정보 조회api( https://kapi.kakao.com/v2/user/m )를 요청함 

val userHeaders = HttpHeaders().apply {
    add("Authorization", "Bearer $accessToken")
    contentType = MediaType.APPLICATION_JSON
}

val userRequest = HttpEntity(null, userHeaders)
val userResponse = restTemplate.exchange(userInfoUri, HttpMethod.GET, userRequest, Map::class.java).body

val kakaoAccount = userResponse?.get("kakao_account") as Map<*, *>
val profile = kakaoAccount["profile"] as Map<*, *>
val nickname = profile["nickname"] as? String ?: "unknown"

내 프로젝트의 경우에는 nickname을 추출해서 회원가입, 로그인 과정으로 넘어감

 

db에 회원정보가 있으면 로그인을 시도하고, 회원정보가 없으면 db에 회원정보를 저장함 

val existingUser = memberRepository.findByUserName(nickname)
var userId: Long? = existingUser?.id

if (existingUser == null) {
    val newUser = memberRepository.save(
        MemberEntity(
            userName = nickname,
            password = UUID.randomUUID().toString(),
        )
    )
    userId = newUser.id
}

 

해당 회원의 userId를 바탕으로 JWT 토큰을 생성함 

val token = jwtTokenManager.generateTokenResponse(memberId = userId!!)
return mapOf("token" to token)

 

 

카카오 로그인 테스트

  • http://localhost:8080/api/auth/kakao/login 경로로 접근시 카카오 로그인 창 표시
  • 필수 동의 항목 체크하고 로그인시 http://localhost:8080/api/auth/kakao/callback?code=xxxx식의 url이 발급됨
  • 해당 url접근시 jwt토큰이 발급됨

 

카카오 로그인 테스트 시연 영상: https://www.youtube.com/watch?v=fGHtHYHhzck