내배캠/TIL

카카오 로그인 개발하기

모닝펄슨 2024. 9. 9. 19:50

모든 웹 사이트에서 회원가입 과정을 거치는 것은 사용자에게 부담이 된다. 매번 회원가입 과정을 수행해야 할 뿐 아니라 웹 사이트마다 다른 아이디와 비밀번호를 기억해야 한다. 웹 사이트를 운영하는 측에서도 회원들의 개인정보를 지켜야하는 역할이 부담된다. 바이러스와 백신의 관계처럼 발전하는 해킹 기술을 막기 위해 보안을 강화하는 노력이 지속적으로 필요하기 때문이다.

 

이런 문제를 해결하기 위해 OAuth를 사용한 소셜 로그인이 등장했다.

 

 

OAuth 란?

OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.

사용자가 애플리케이션에게 든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜로, OAuth를 사용하는 서비스 제공자는 대표적으로 구글, 페이스북 등이 있고 국내에는 네이버와 카카오가 있다.

 

아래는 카카오 로그인의 큰 흐름을 설명해준다.

 

 

 

그럼 이제 카카오 로그인을 직접 사용해보자.

 

 

애플리케이션 등록하기

 

1. 회원가입

 

 

2. 내 애플리케이션 메뉴 선택 > 애플리케이션 추가하기

 

 

3. 앱 아이콘, 앱 이름, 사업자명 저장

 

 

4. 사이트 도메인 등록하기

  1. 애플리케이션 선택
  2. 플랫폼 메뉴 선택 > 플랫폼 설정하기 클릭
  3. Web 플랫폼 등록
  4. 사이트 도메인 입력

 

 

5. 카카오로 로그인 했을 때 인가토큰을 받게 될 Redirect URI (callback) 를 설정하기

 

활성화 설정

 

 

6. 동의항목 설정하기

카카오 서버로부터 사용자의 어떤 정보를 받을지 정할 수 있다.

1. 닉네임을 '필수 동의'로 받는다.

2. 카카오계정(이메일) 정보를 '선택 동의'로 받는다.

 

 

이제 카카오 서버를 사용할 준비는 끝났으니 자신의 사이트와 연동해보자.

 

 

 

카카오 사용자 정보 가져오기

 

https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={REDIRECT_URI}&response_type=code

 

위의 REST_API_KEY 부분에 자신의 REST API 키를 입력한다.

 

 

사용자가 카카오 로그인 페이지를 통해 '동의하고 계속하기'를 클릭하면, Redirect URI (callback)로 '인가코드'가 전달된다.

 

 

http://localhost:8080/users/kakao/callback 은 내가 설정한 redirect url이다. 이곳으로 인가코드를 전달받고 해당 코드로 accessToken 및 사용자 정보를 가져올 수 있다.

 

[카카오 로그인 컨트롤러]

package com.sparta.outsourcing_project.domain.user.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.sparta.outsourcing_project.domain.user.service.KakaoService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/users/kakao")
public class KakaoController {

    private final KakaoService kakaoService;

    @GetMapping("/callback")
    public ResponseEntity<String> kakaoLogin(@RequestParam String code) throws JsonProcessingException {
        return ResponseEntity.ok(kakaoService.kakaoLogin(code));
    }
}

 

 

code로 accessToken을 받고, 해당 accessToken으로 로그인한 사용자 정보를 가져온다. 나는 카카오 계정 테이블을 따로 만들지 않고 user 테이블에 kakaoId 컬럼을 새로 추가해서 구현했다. 카카오로 로그인한 이메일 계정이 만약 아예 존재하지 않는다면 새로 회원가입을 시켜서 해당 이메일로 user를 새로 만들어 저장했고, 똑같은 이메일의 유저 객체가 있다면 해당 객체의 kakaoId 컬럼에 해당 이메일을 추가했다. 만약 동일한 카카오 계정을 갖고 있는 유저가 있다면 그냥 해당 유저를 반환하도록 했다. 최종적으로는 jwt 토큰을 만들어 반환하고 해당 토큰으로 로그인이 가능하다. 

 

 

[카카오 로그인 서비스]

package com.sparta.outsourcing_project.domain.user.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.outsourcing_project.config.PasswordEncoder;
import com.sparta.outsourcing_project.config.jwt.JwtUtil;
import com.sparta.outsourcing_project.domain.user.dto.response.KakaoUserInfoDto;
import com.sparta.outsourcing_project.domain.user.entity.User;
import com.sparta.outsourcing_project.domain.user.enums.UserType;
import com.sparta.outsourcing_project.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.util.UUID;

@Slf4j(topic = "kakao login")
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class KakaoService {

    private final UserRepository userRepository;
    private final RestTemplate restTemplate;
    private final JwtUtil jwtUtil;
    private final PasswordEncoder passwordEncoder;

    @Value("${restApi.secret.key}")
    private String restApiKey;

    private String getKakaoAccessToken(String code) throws JsonProcessingException {
        // 요청 URL
        URI uri = UriComponentsBuilder
                .fromUriString("https://kauth.kakao.com")
                .path("/oauth/token")
                .encode()
                .build()
                .toUri();

        // HTTP Header
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");

        // HTTP Body
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", restApiKey);
        body.add("redirect_uri", "http://localhost:8080/users/kakao/callback");
        body.add("code", code);

        RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity.post(uri).headers(headers).body(body);

        // HTTP 요청 보내기
        ResponseEntity<String> response = restTemplate.exchange(requestEntity, String.class);

        // 엑세스 토큰 파싱
        JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
        return jsonNode.get("access_token").asText();
    }

    private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        // 요청 URL
        URI uri = UriComponentsBuilder
                .fromUriString("https://kapi.kakao.com")
                .path("/v2/user/me")
                .encode()
                .build()
                .toUri();

        // HTTP header
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");

        RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity.post(uri).headers(headers).body(new LinkedMultiValueMap<>());

        // HTTP 요청
        ResponseEntity<String> response = restTemplate.exchange(requestEntity, String.class);

        JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
        Long id = jsonNode.get("id").asLong();
        String email = jsonNode.get("kakao_account").get("email").asText();

        log.info("카카오 사용자 정보 - id : {}, email : {}", id, email);
        return new KakaoUserInfoDto(id, email);
    }

    private User registerKakaoUserIfNew(KakaoUserInfoDto kakaoUserInfoDto) {
        User kakaoUser = userRepository.findByKakaoId(kakaoUserInfoDto.getId()).orElse(null);

        if(kakaoUser == null) {
            User sameEmailUser = userRepository.findByEmail(kakaoUserInfoDto.getEmail()).orElse(null);
            if(sameEmailUser != null) {
                // 같은 email 가진 user 있을 경우 kakaoId 업데이트 후 반환
                kakaoUser = sameEmailUser;
                kakaoUser.updateKakaoId(kakaoUser.getKakaoId());
            } else {
                // 신규 회원가입
                String password = passwordEncoder.encode(UUID.randomUUID().toString());
                String email = kakaoUserInfoDto.getEmail();
                kakaoUser = new User(email, password, UserType.CUSTOMER, kakaoUserInfoDto.getId());
                userRepository.save(kakaoUser);
            }
        }
        return kakaoUser;
    }

    @Transactional
    public String kakaoLogin(String code) throws JsonProcessingException {
        // 인가 코드로 엑세스 토큰 요청
        String accessToken = getKakaoAccessToken(code);

        // 토큰으로 카카오 API 호출 - 엑세스 토큰으로 카카오 사용자 정보 가져오기
        KakaoUserInfoDto kakaoUserInfoDto = getKakaoUserInfo(accessToken);

        // 신규 회원일 시 회원가입
        User kakaoUser = registerKakaoUserIfNew(kakaoUserInfoDto);

        // JWT 토큰 반환
        return jwtUtil.generateToken(kakaoUser.getId(), kakaoUser.getEmail(), kakaoUser.getUserType());
    }

}