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