반응형

react와 spring boot를 이용해서 카카오 로그인 기능을 만들어보겠습니다.

첫 로그인이라면 간단한 회원 정보를 입력받을 것이고,

첫 로그인이 아니라면 그대로 로그인을 진행하겠습니다.

 

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

 

먼저 진행하기에 앞서 위 문서를 참조하면서 개발을 진행하겠습니다.

 

일단 아래 사이트에 접속합니다.

https://developers.kakao.com/

 

그 후, 내 애플리케이션 탭에 들어가서 애플리케이션을 하나 추가해줍니다.

해당 애플리케이션으로 접속하면 키가 발급되어있는데, REST API 키를 이용할 것이니 기억해둡니다.

 

kakao dev page

왼쪽에 메뉴를 클릭해서 카카오 로그인 메뉴로 이동합니다.

kakao dev redirect page

활성화를 ON으로 해주고, Redirect URI를 지정합니다.

처음에 브라우저에서 카카오 로그인 버튼을 누르면 로그인 페이지로 이동될 것입니다.

그곳에서 카카오 계정으로 로그인하면, redirect시켜줄 uri를 지정하는 것입니다.

그 uri로 redirect되면, query parameter로 authorization code를 발급해주는데, 이 코드 값을 이용해서 카카오 인증서버에서 액세스 토큰을 발급할 수 있습니다.

그렇게 발급된 액세스 토큰으로 카카오의 auth 관련 API를 이용하는 것입니다.

(소셜 로그인이 여럿 있을 경우를 대비하여, 구분하기 위해 provider라는 query parameter를 임의로 지정해두었습니다.)

 

먼저 카카오 로그인 버튼 컴포넌트를 작성해보겠습니다.

 

https://developers.kakao.com/tool/resource/login

카카오 로그인하기의 이미지는 위 사이트에서 원하는 사이즈에 맞춰 다운로드할 수 있습니다.

기호에 맞게 다운로드 받은 뒤, /public 디렉토리에 넣어줍니다.

 

import React from "react";
import { IconButton } from "@material-ui/core";
import { kakaoClientId, kakaoRedirectUri } from "../config/config";

const loginUri = `https://kauth.kakao.com/oauth/authorize?client_id=${kakaoClientId}\
&redirect_uri=${kakaoRedirectUri}&response_type=code`;

const KakaoLoginButton = () => {
  return (
    <IconButton>
      <a href={loginUri} rel="noopener noreferrer">
        <img src="/kakao_login_medium_narrow.png" />
      </a>
    </IconButton>
  );
};

export default KakaoLoginButton;

 

위처럼 카카오 로그인하기 버튼 컴포넌트를 작성해주었습니다.

a 태그의 href에 작성된 URI는, 처음 게시해둔 카카오 문서의 "인가 코드 받기" 부분을 참조바랍니다.

해당 a 태그를 클릭하면, 카카오 로그인하기 페이지로 넘어가게 됩니다.

그곳에서 로그인하면, 아까 지정해둔 redirect page로 돌아가면서 액세스 토큰을 발급받기 위한 Authorization code를 query parameter로 얻게 됩니다.

kakaoClientId는 처음 애플리케이션을 등록하고 발급받았던 REST API 키 입니다.

이 키 값은 비밀로 관리되어야하나 싶었는데, 애초에 URI로 노출되는 키 값이고 클라이언트를 위한 값이라 노출되어도 상관없는 듯합니다.

아무튼, 저 페이지로 이동하여 로그인을 하고 나면, 초기에 지정해두었던 페이지로 redirect 될 것입니다.

구현 방식은 다양하겠지만, 저 같은 경우 다음의 방법으로 진행하였습니다.

1. 리다이렉트 페이지로 이동하면, 직접 구축해둔 별도의 백엔드 서버로 발급받은 code값을 발송해줍니다.

2. 백엔드 서버에서는 해당 code값으로 카카오 인증 서버에 접근하여, 액세스 토큰을 발급받아서 응답해줄 것입니다. (프론트 단에서 처리해주려했지만, cors 문제로 액세스 토큰이 발급 안되는 문제가 있어서 백엔드 단으로 넘겼습니다.)

3. 프론트에서는 발급받은 액세스 토큰으로, 다시 백엔드 서버에 로그인을 요청합니다.

4. 백엔드 서버에서는 액세스 토큰을 받아서 카카오에 등록된 사용자 정보를 받아옵니다.(사용자 아이디 값을 조합하기 위해 id값을 조회할 것입니다.)

5. 이 때, 카카오 로그인 사용자의 추가적인 회원 정보가 이미 우리의 데이터베이스에 저장되어있다면, 바로 로그인 처리를 해 줄 것입니다.

6. 하지만, 우리의 데이터베이스에 카카오 로그인 사용자의 회원 정보가 저장되어있지 않다면, 회원 정보를 입력하라는 별도의 응답 코드를 보내줄 것입니다.

7. 프론트 단에서 회원 정보를 입력받으라는 응답 코드를 받았다면, 회원 정보를 입력시킨 뒤, 다시 백엔드 서버로 회원 정보와 액세스 토큰을 보내줍니다.

8. 액세스 토큰으로 카카오 인증 서버에서 다시 회원 정보를 조회하여, 우리의 데이터베이스에서 사용할 아이디 값을 생성하여 저장한 뒤, 로그인 처리시켜줍니다.

먼저, 리다이렉트 페이지로 이동했을 때의 컴포넌트 처리는 다음과 같습니다.

(불필요한 부분은 생략하였습니다.)

import React, { useCallback, useEffect, useRef } from "react";
import { Button, TextField, CircularProgress } from "@material-ui/core";
import { useDispatch, useSelector } from "react-redux";
import Router from "next/router";
import useInput from "../hooks/useInput";
import { useRouter } from "next/router";
import {
  clearRegisterState,
  registerFailure,
  registerByProviderRequest,
  loginByProviderRequest,
} from "../reducers/user";
import ErrorCollapse from "./ErrorCollapse";
import axios from "axios";
import styled from "styled-components";

...

const RegisterByProviderForm = () => {
  const dispatch = useDispatch();
  const accessToken = useRef(null);
  const router = useRouter();
  const {
    registerDone,
    registerError,
    registerLoading,
    loginError,
    loginDone,
  } = useSelector((state) => state.user);
  const { code, provider } = router.query; // query parameter에서 code값과 provider(어떤 소셜로그인인지) 추출
  ...

  useEffect(async () => { // 컴포넌트가 마운트 되면, 일단 액세스 토큰 요청
    if (!code || !provider) return;
    const response = await axios.post("/api/social/get-token-by-provider", {
      code,
      provider,
    });
    accessToken.current = response.data.data["access_token"]; // 백엔드 서버에서 토큰 발급
    dispatch( // 발급받은 액세스 토큰으로 로그인 요청
      loginByProviderRequest({
        accessToken: accessToken.current,
        provider,
      })
    );
  }, []);

  useEffect(() => {
    // 에러 코드 -1020 로그인은 됐지만, 회원 정보 아직 입력되지 않은 경우
    if (!loginError || loginError == -1020) return;
    // 즉, 카카오 인증 서버에서 로그인은 되었지만 사용자의 회원 정보가 아직
    // 우리의 데이터베이스에 저장되지 않은 상황이므로 현재 페이지에 잔류함
    Router.replace("/");
  }, [loginError]);

  useEffect(() => { // 로그인 또는 회원가입이 완료되면, "/"로 리다이렉트
    if (!loginDone && !registerDone) return;
    Router.replace("/");
  }, [loginDone, registerDone]);

  ...

  const onSubmit = useCallback(
    (e) => {
      e.preventDefault();
      ...
      dispatch( // 회원정보를 입력받았으면 액세스 토큰으로 회원가입 요청
        registerByProviderRequest({
          accessToken: accessToken.current,
          provider,
          username,
          nickname,
        })
      );
    },
    [username, nickname]
  );

  return (
    <>
      {loginError ? (
        <RegisterByProviderFormWrapper>
          <h1>회원 정보를 입력해주세요.</h1>
          ...
          <form onSubmit={onSubmit}>
            <TextField
              required
              label="사용자 이름"
              value={username}
              onChange={onChangeUsername}
              variant="outlined"
              className="register-text-field"
            />
           ...
          </form>
        </RegisterByProviderFormWrapper>
      ) : (
        ... 로딩중
      )}
    </>
  );
};

export default RegisterByProviderForm;

자세한 내용은 주석을 참고 바랍니다.

접속하면, "/api/social/get-token-by-provider" 로 code와 provider를 보내주면서 벡엔드 서버로 post 요청을 하는데, code값을 이용해서 액세스 토큰을 프론트 단으로 발급받는 과정입니다.

백엔드 서버는 스프링부트로 작성하였는데, 해당 요청의 소스코드는 다음과 같습니다.

public class SocialController {
    private final KakaoService kakaoService;
    private final ResponseService responseService;

    @PostMapping(value = "/social/get-token-by-provider")
    public Result getTokenByProvider(
            @Valid @RequestBody SocialTokenRequestDto requestDto) {
        if(Objects.equals(requestDto.getProvider(), "kakao")) {
            return responseService.getSingleResult(kakaoService.getKakaoTokenInfo(requestDto.getCode()));
        }
        throw new NotRegisteredProviderException();
    }
}

provider의 이름에 따라, KakaoService를 이용해서 토큰 정보를 조회하여 반환해줄 것입니다.

 

KakaoService의 소스코드는 다음과 같습니다.

@RequiredArgsConstructor
@Service
public class KakaoService {
    private final RestTemplate restTemplate;
    private final Gson gson;

    @Value("${domain}")
    private String domain;

    @Value("${kakao.clientId}")
    private String clientId;

    @Value("${kakao.redirect.uri}")
    private String redirectUri;

    public String generateKakaoUid(String accessToken) { // 카카오 API에 접근하여 아이디값 생성
        return "{kakao}" + getKakaoIdInfo(accessToken).getId();
        // 각 소셜 서비스들의 구분자로 앞 단에 "{provider}" 를 붙여주었습니다.
    }

    public KakaoTokenInfoDto getKakaoTokenInfo(String code) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", clientId);
        params.add("redirect_uri", domain + redirectUri);
        params.add("code", code);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<String> response = restTemplate.postForEntity("https://kauth.kakao.com/oauth/token", request, String.class);

        if(response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), KakaoTokenInfoDto.class);
        }
        throw new KakaoCommunicationFailureException();
    }

    private KakaoIdInfoDto getKakaoIdInfo(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.set("Authorization", "Bearer " + accessToken);

        ResponseEntity<String> response = restTemplate.postForEntity("https://kapi.kakao.com/v2/user/me", new HttpEntity<>(null, headers), String.class);
        if(response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), KakaoIdInfoDto.class);
        }
        throw new KakaoCommunicationFailureException();
    }
}

토큰을 발급받는 "getKakaoTokenInfo"의 요청 부분은, 카카오 문서의 "토큰 받기" 부분을 참조 바랍니다.

카카오 서버에서 id값을 조회하는 "getKakaoIdInfo"의 요청 부분은, 카카오 문서의 "사용자 정보 가져오기" 부분을 참조 바랍니다.

프론트 단에서, 액세스 토큰을 응답 받았으면, 이어서 loginByProviderRequest() 액션을 발행할 것입니다.

해당 부분의 redux-saga의 소스코드는 생략하겠습니다.

"/sign/login-by-provider"로 post 요청을 보내게 될텐데, 해당 부분의 백엔드 서버 소스코드는 다음과 같습니다.

// SignController
@PostMapping(value = "/sign/login-by-provider")
public Result loginByProvider(
        @ApiIgnore HttpServletResponse response,
        @Valid @RequestBody UserLoginByProviderRequestDto requestDto) {
    UserLoginResponseDto result;
    if(Objects.equals(requestDto.getProvider(), "kakao")) {
        result = signService.loginByKakao(requestDto);
    } else {
        throw new NotRegisteredProviderException();
    }
    // ... do something
    return responseService.getSingleResult(result);
}

provider로 구분하여 해당하는 로그인 메소드를 호출해줄 것입니다.

 

loginByKakao소스코드는 다음과 같습니다.

public UserLoginResponseDto loginByKakao(UserLoginByProviderRequestDto requestDto) {
    String uid = kakaoService.generateKakaoUid(requestDto.getAccessToken());
    User user = userRepository.findByUid(uid).orElseThrow(NotRegisteredProviderUserInfoException::new);
    ...
    return ...;
}

위에서 작성해뒀던 KakaoService의 generateKakaoUid를 통해, 우리의 데이터베이스에서 사용할 회원 아이디를 생성해서 저장해준 뒤, 로그인 처리시켜줍니다.

하지만 회원 정보가 저장되어 있지않다면, NotRegisteredProviderUserInfoException 예외를 발생시켜줄 것입니다.

이에 대한 에러를 advice에서 캐치하여 프론트 단 소스코드에서 작성된 것처럼 -1020 이라는 응답코드를 보내줍니다.

그러면 프론트 단에서는 이 응답코드를 받고, 현재 페이지에 잔류하여 회원 정보를 입력받도록 할 것입니다.

회원 정보 입력이 끝났으면, 다시 액세스 토큰과 회원 정보를 백엔드 서버로 전송하여 회원가입을 처리시켜줍니다.

registerByProviderRequest 액션을 발행시킬텐데, 해당 부분의 redux-saga 소스코드는 생략하겠습니다.

"/sign/register-by-provider"로 POST요청을 보내게 될텐데, 소스 코드는 다음과 같습니다.

// SignController
@PostMapping(value = "/sign/register-by-provider")
public Result registerByProvider(
        @ApiIgnore HttpServletResponse response,
        @Valid @RequestBody UserRegisterByProviderRequestDto requestDto) {
    UserLoginResponseDto result;
    if(Objects.equals(requestDto.getProvider(), "kakao")) {
        result = signService.registerByKakao(requestDto);
    } else {
        throw new NotRegisteredProviderException();
    }
    ...
    return responseService.getSingleResult(result);
}

이번에도 provider로 구분하여 회원가입을 처리시켜줍니다.

public UserLoginResponseDto registerByKakao(UserRegisterByProviderRequestDto requestDto) {
    String uid = kakaoService.generateKakaoUid(requestDto.getAccessToken());
    // ... validation
    User user = userRepository.save(
           ...
    );
    return ...
}

카카오 회원가입 사용자도 중복 가입이 되었는지 validation을 거쳐준 뒤, 데이터베이스에 저장이 완료되었으면 로그인 처리시켜줍니다.

 

  useEffect(() => { // 로그인 또는 회원가입이 완료되면, "/"로 리다이렉트
    if (!loginDone && !registerDone) return;
    Router.replace("/");
  }, [loginDone, registerDone]);

로그인 또는 회원가입이 성공적으로 끝마치면, "/"로 리다이렉트 시켜주면 됩니다.

 

* 예전에 작성했던 포스트라 썩 좋은 코드는 아닌데, 알맞게 리팩토링하는 과정이 필요할 것 같습니다.

반응형

+ Recent posts