이 연재글은 SpringBoot2로 Rest api 만들기의 10번째 글입니다.

이번 장에서는 Rest api에 카카오 로그인 연동을 해보겠습니다. 카카오의 로그인은 Oauth2 방식을 따르고 있습니다. Oauth2에 대한 자세한 플로우는 다음 링크를 참고하시기 바랍니다. https://d2.naver.com/helloworld/24942
Facebook 및 Google의 로그인도 Oauth2 방식인데, 카카오에 비해 앱을 생성하는 게 쉽지 않습니다. public 하게 접근 가능한 개인정보 보호 방침 페이지를 요구할 뿐만 아니라. Redirect page 세팅 시 https주소가 아니면 세팅이 되지 않습니다. localhost 또한 https를 요구하므로 현재 테스트 상황에서는 연동방법이 적합하지 않다 생각하여 최종 kakao연동을 택하게 되었습니다.

카카오 로그인 연동 Flow

카카오 로그인 연동 순서

  • 사전작업 – 카카오 개발자 센터에 APP 생성
  • 로그인 페이지 및 콜백 페이지 연동
  • accessToken으로 가입 및 로그인 처리

사전 작업 – 카카오 개발자 센터에 APP 생성

https://developers.kakao.com/ 사이트로 이동하여 카카오 ID로 로그인합니다. 개발자로 등록이 안되어 있으면 개발자로 등록합니다.

앱 만들기 화면에서 아이콘/이름/회사명을 적고 앱 만들기 클릭합니다.

애플리케이션 생성 완료 화면입니다. 앱 키는 연동을 위해 복사해 놓습니다. (설정 – 일반에서 확인 가능)

메뉴 – 설정 – 일반에 들어가 플랫폼을 추가합니다. 플랫폼은 웹을 선택합니다. 사이트 도메인은 로컬 PC에서 테스트할 것이므로 http://localhost:8080 을 입력합니다. 향후 서비스를 위한 도메인도 추가로 입력 가능합니다.

플랫폼 탭에 사이트 도메인이 등록된 것을 확인 가능합니다. RedirectPath는 카카오 로그인 연동 후 인증 성공시 Callback 받을 URL입니다. 다른 social 로그인과 연동 시 충돌 방지를 위해 적당한 path로 변경합니다.

메뉴 – 설정 – 사용자 관리
카카오 계정으로 로그인 및 API를 사용하려면 사용자 관리를 활성화합니다.

카카오 로그인 및 콜백 페이지 연동

카카오 로그인은 다음과 같은 순서로 프로세스가 진행됩니다.

  • 앱 등록 정보로 카카오 로그인 페이지를 띄움
  • 카카오 로그인을 완료 시 앱 연동 화면이 나오고 동의를 통해 앱과 카카오 계정을 연결
  • 앱에 설정한 콜백 페이지가 인증 코드와 함께 호출
  • 전달된 콜백 페이지에서 인증코드로 token을 얻어 화면에 표시

Kakao와 통신이 필요하므로 SpringRestApiApplication에 RestTemplate Bean을 추가합니다.

@SpringBootApplication
public class SpringRestApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringRestApiApplication.class, args);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
}

카카오 연동 시 결과 Json을 객체로 맵핑하기 위해 Gson 라이브러리를 사용하겠습니다. build.gradle에 다음 라이브러리를 추가합니다.

implementation 'com.google.code.gson:gson'

kakao api와 연동하기 위한 설정 정보를 application.yml에 추가합니다.

social:
  kakao:
    client_id: XXXXXXXXXXXXXXXXXXXX # 앱생성시 받은 REST API 키
    redirect: /social/login/kakao
    url:
      login: https://kauth.kakao.com/oauth/authorize
      token: https://kauth.kakao.com/oauth/token
      profile: https://kapi.kakao.com/v2/user/me
url:
  base: http://localhost:8080

로그인 및 콜백 처리를 위한 SocialController를 controller.common package 하위에 생성합니다. 카카오 api와의 연동은 다음 링크에서 자세한 내용을 볼 수 있습니다.
https://developers.kakao.com/docs/restapi/user-management#%EB%A1%9C%EA%B7%B8%EC%9D%B8
/social/login/kakao 화면은 카카오 로그인 팝업을 띄우기 위해 버튼만 존재하는 페이지입니다. 카카오 로그인 화면을 띄우려면 다음과 같은 형식으로 호출해야 하는데요. Controller에서 해당 포맷에 맞는 URL을 만들어 login.ftl에 보내주게 됩니다.
https://kauth.kakao.com/oauth/authorize?client_id={app_key}&redirect_uri={redirect_uri}&response_type=code

package com.rest.api.controller.common;

// import 생략

@RequiredArgsConstructor
@Controller
@RequestMapping("/social/login")
public class SocialController {

    private final Environment env;
    private final RestTemplate restTemplate;
    private final Gson gson;
    private final KakaoService kakaoService;

    @Value("${spring.url.base}")
    private String baseUrl;

    @Value("${spring.social.kakao.client_id}")
    private String kakaoClientId;

    @Value("${spring.social.kakao.redirect}")
    private String kakaoRedirect;

    /**
     * 카카오 로그인 페이지
     */
    @GetMapping
    public ModelAndView socialLogin(ModelAndView mav) {

        StringBuilder loginUrl = new StringBuilder()
                .append(env.getProperty("spring.social.kakao.url.login"))
                .append("?client_id=").append(kakaoClientId)
                .append("&response_type=code")
                .append("&redirect_uri=").append(baseUrl).append(kakaoRedirect);

        mav.addObject("loginUrl", loginUrl);
        mav.setViewName("social/login");
        return mav;
    }

    /**
     * 카카오 인증 완료 후 리다이렉트 화면
     */
    @GetMapping(value = "/kakao")
    public ModelAndView redirectKakao(ModelAndView mav, @RequestParam String code) {
        mav.addObject("authInfo", kakaoService.getKakaoTokenInfo(code));
        mav.setViewName("social/redirectKakao");
        return mav;
    }
}

카카오 token api 연동시 맵핑을 위한 모델 생성

package com.rest.api.model.social;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class RetKakaoAuth {
    private String access_token;
    private String token_type;
    private String refresh_token;
    private long expires_in;
    private String scope;
}

카카오 로그인 페이지 작성

resources/templates/social 하단에 login.ftl을 생성합니다.

로그인 화면에서는 KakaoLogin버튼을 누르면 서버에서 전달받은 URL을 팝업으로 띄우는 코드로만 이루어져 있습니다. 카카오 로그인 팝업이 열리고 로그인 후에는 앱을 연동하지 않은 경우에는 연동 화면이 나옵니다.

<button onclick="popupKakaoLogin()">KakaoLogin</button>
<script>
    function popupKakaoLogin() {
        window.open('${loginUrl}', 'popupKakaoLogin', 'width=700,height=500,scrollbars=0,toolbar=0,menubar=no')
    }
</script>

위 팝업에서 동의를 완료하면 카카오 내부적으로 아빠 프로그래머 앱과 카카오 회원정보를 연동시킵니다. 그리고 설정된 Redirect주소로 다음과 같이 페이지를 리다이렉션 해줍니다.
http://localhost:8080/social/login/kakao?code=XXXXXXXXXXX

Controller에서 /social/login/kakao의 리소스를 처리하는 부분을 보면 파라미터로 전달된 code 정보로 카카오의 access_token을 얻는 api를 호출하는 것을 확인할 수 있습니다.

호출하는 형태는 다음과 같습니다.(개발자 사이트 참고) 전달받은 코드를 다음과 같은 주소 형태로 code를 실어 보내면 token정보를 담은 Json객체를 전달받을 수 있습니다.

curl -v -X POST https://kauth.kakao.com/oauth/token \
 -d 'grant_type=authorization_code' \
 -d 'client_id={app_key}' \
 -d 'redirect_uri={redirect_uri}' \
 -d 'code={authorize_code}'
{  "access_token":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
   "token_type":"bearer",
   "refresh_token":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
   "expires_in":43199,
   "scope":"Basic_Profile"
}

access_token은 카카오 api와 통신할 때 인증 대신 실어 보내는 정보입니다. 토큰 정보를 헤더에 token_type: bearer 방식으로 실어 보내면 api 결과를 볼 수 있습니다.
“Authorization: bearer xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx”
refresh_token은 access_token이 만료되면 갱신된 token을 다시 받을 때 사용하는 토큰 값입니다. expires_in은 access_token의 만료시간을 나타냅니다. scope는 해당 토큰으로 접근할 수 있는 리소스에 대한 권한 정보입니다. 개발자 센터에서 앱을 생성하고 사용자 설정을 할 때 기본 세팅으로 하였으므로 Basic_Profile에 대한 접근 권한이 있다고 표시됩니다. 만약 개발자 센터에서 카카오 계정(이메일)을 추가로 등록하면 scope 내용에 추가적인 정보가 표시됩니다.

사실 Rest API 서버에서는 카카오 로그인 화면 연동에 대한 처리가 필요 없습니다. 실제로 연동할 때는 클라이언트(web 또는 app)에서 로그인까지 완료하고 access_token을 api서버에 전달해주도록 프로세스가 진행돼야 합니다. api 서버에서는 access_token을 가지고 다음 작업을 진행하게 됩니다. 여기서는 테스트를 위해 클라이언트(웹 프로젝트)를 따로 구축 하기엔 부담이 크므로 api 서버가 카카오 웹 로그인까지 같이 처리했습니다.

이제 api 연동하는 부분에 대해 살펴보겠습니다. api에서는 로그인 완료 시 얻은 access_token을 이용하여 다음과 같은 작업을 진행할 것입니다.

  • access_token으로 로그인하는 api 생성
    • access_token으로 카카오에 profile api 정보를 호출
    • 카카오 정보로 가입이 되어있는지 DB 확인.
    • 비 가입자의 경우엔 로그인 실패 처리
    • 기 가입자일 경우 JWT 토큰 발급
  • access_token으로 신규가입을 하는 api 생성
    • access_token으로 카카오에 profile api 정보를 호출
    • 카카오 정보로 가입이 되어있는지 DB 확인.
    • 기 가입자의 경우엔 가입 실패 처리
    • 비 가입자의 경우는 신규 가입 처리 후 JWT 토큰 발급

User Entity 수정

회원의 서비스 제공자를 알기 위해 provider 필드를 추가합니다. 소셜 가입의 경우 암호가 필요 없으므로 password는 null 허용으로 변경합니다.

 @Id // pk
 @GeneratedValue(strategy = GenerationType.IDENTITY)
 private long msrl;
 @Column(nullable = false, unique = true, length = 50)
 private String uid;
 @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
 @Column(length = 100)
 private String password;
 @Column(nullable = false, length = 100)
 private String name;
 @Column(length = 100)
 private String provider;

UserJpaRepo에 소셜 회원정보 조회 메서드 추가

uid 및 provider정보로 소셜 회원정보를 조회하는 메서드입니다.
Optional<User> findByUidAndProvider(String uid, String provider);

Kakao 유저정보를 담을 객체 생성

@Getter
@Setter
@ToString
public class KakaoProfile {
    private Long id;
    private Properties properties;

    @Getter
    @Setter
    @ToString
    private static class Properties {
        private String nickname;
        private String thumbnail_image;
        private String profile_image;
    }
}

통신 오류를 처리 할 CCommunicationException을 선언

카카오 api 통신 중 문제가 발생하면 발생시킬 예외입니다.

public class CCommunicationException extends RuntimeException {
    public CCommunicationException(String msg, Throwable t) {
        super(msg, t);
    }
    public CCommunicationException(String msg) {
        super(msg);
    }
    public CCommunicationException() {
        super();
    }
}
# exception_ko.yml
communicationError:
  code: "-1004"
  msg: "통신 중 오류가 발생하였습니다."
# exception_en.yml
communicationError:
  code: "-1004"
  msg: "An error occurred during communication."
@ExceptionHandler(CCommunicationException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public CommonResult communicationException(HttpServletRequest request, CCommunicationException e) {
        return responseService.getFailResult(Integer.valueOf(getMessage("communicationError.code")), getMessage("communicationError.msg"));
    }

Kakao연동을 담당할 KakaoService를 생성

service.social package를 생성하고 kakao api 연동 메서드들을 Service로 생성하여 사용합니다.

package com.rest.api.service.user;

import 생략

@RequiredArgsConstructor
@Service
public class KakaoService {

    private final RestTemplate restTemplate;
    private final Environment env;
    private final Gson gson;

    @Value("${spring.url.base}")
    private String baseUrl;

    @Value("${spring.social.kakao.client_id}")
    private String kakaoClientId;

    @Value("${spring.social.kakao.redirect}")
    private String kakaoRedirect;

    public KakaoProfile getKakaoProfile(String accessToken) {
        // Set header : Content-type: application/x-www-form-urlencoded
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.set("Authorization", "Bearer " + accessToken);

        // Set http entity
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(null, headers);
        try {
            // Request profile
            ResponseEntity<String> response = restTemplate.postForEntity(env.getProperty("spring.social.kakao.url.profile"), request, String.class);
            if (response.getStatusCode() == HttpStatus.OK)
                return gson.fromJson(response.getBody(), KakaoProfile.class);
        } catch (Exception e) {
            throw new CCommunicationException();
        }
        throw new CCommunicationException();
    }

    public RetKakaoAuth getKakaoTokenInfo(String code) {
        // Set header : Content-type: application/x-www-form-urlencoded
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        // Set parameter
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", kakaoClientId);
        params.add("redirect_uri", baseUrl + kakaoRedirect);
        params.add("code", code);
        // Set http entity
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        ResponseEntity<String> response = restTemplate.postForEntity(env.getProperty("spring.social.kakao.url.token"), request, String.class);
        if (response.getStatusCode() == HttpStatus.OK) {
            return gson.fromJson(response.getBody(), RetKakaoAuth.class);
        }
        return null;
    }
}

SignController에 카카오 로그인 기능을 추가

비회원인 경우 CUserNotFoundException 예외를 발생시킵니다. 기 회원인 경우 JWT토큰을 생성하여 발급합니다.

@ApiOperation(value = "소셜 로그인", notes = "소셜 회원 로그인을 한다.")
@PostMapping(value = "/signin/{provider}")
public SingleResult<String> signinByProvider(
            @ApiParam(value = "서비스 제공자 provider", required = true, defaultValue = "kakao") @PathVariable String provider,
            @ApiParam(value = "소셜 access_token", required = true) @RequestParam String accessToken) {

        KakaoProfile profile = kakaoService.getKakaoProfile(accessToken);
        User user = userJpaRepo.findByUidAndProvider(String.valueOf(profile.getId()), provider).orElseThrow(CUserNotFoundException::new);
        return responseService.getSingleResult(jwtTokenProvider.createToken(String.valueOf(user.getMsrl()), user.getRoles()));
}

SpringSecurityConfig에서 소셜 로그인 주소를 모두 접근 가능하게 설정

로그인 화면은 아무나 접근할 수 있도록 처리합니다.

.authorizeRequests()
     .antMatchers("/*/signin", "/*/signin/**", "/*/signup", "/social/**").permitAll()

Swagger 로그인 테스트

카카오 로그인시 DB에 가입 데이터가 없어서 오류가 발생합니다.

SignController에 카카오 가입 기능 추가

카카오 accessToken과 이름을 받아 가입하는 기능을 추가합니다.
accessToken 외에 가입 시 추가로 필요한 정보가 있으면 파라미터로 받으면 되며 이때 카카오 프로필 정보의 닉네임, 프로필 이미지 등 정보를 이용하여 가입 정보를 채우는 것도 한 방법입니다.

@ApiOperation(value = "소셜 계정 가입", notes = "소셜 계정 회원가입을 한다.")
@PostMapping(value = "/signup/{provider}")
public CommonResult signupProvider(@ApiParam(value = "서비스 제공자 provider", required = true, defaultValue = "kakao") @PathVariable String provider,
                               @ApiParam(value = "소셜 access_token", required = true) @RequestParam String accessToken,
                               @ApiParam(value = "이름", required = true) @RequestParam String name) {

        KakaoProfile profile = kakaoService.getKakaoProfile(accessToken);
        Optional<User> user = userJpaRepo.findByUidAndProvider(String.valueOf(profile.getId()), provider);
        if(user.isPresent())
            throw new CUserExistException();

        userJpaRepo.save(User.builder()
                .uid(String.valueOf(profile.getId()))
                .provider(provider)
                .name(name)
                .roles(Collections.singletonList("ROLE_USER"))
                .build());
        return responseService.getSuccessResult();
}

기 회원 예외 처리 CUserExistException 추가

이미 가입한 회원인데 가입을 시도하려 하면 예외 처리합니다.

public class CUserExistException extends RuntimeException {
    public CUserExistException(String msg, Throwable t) {
        super(msg, t);
    }
    public CUserExistException(String msg) {
        super(msg);
    }
    public CUserExistException() {
        super();
    }
}
@ExceptionHandler(CUserExistException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public CommonResult communicationException(HttpServletRequest request, CUserExistException e) {
        return responseService.getFailResult(Integer.valueOf(getMessage("existingUser.code")), getMessage("existingUser.msg"));
}
# exception_en.yml
existingUser:
  code: "-1005"
  msg: "You are an existing member."
# exception_ko.yml
existingUser:
  code: "-1005"
  msg: "이미 가입한 회원입니다. 로그인을 해주십시오."

SpringSecurityConfig에서 소셜 가입 주소를 모두 접근 가능하게 설정

가입 주소는 누구나 접근 가능하도록 설정에 추가합니다.

.authorizeRequests() // 다음 리퀘스트에 대한 사용권한 체크
                    .antMatchers("/*/signin", "/*/signin/**", "/*/signup", "/*/signup/**", "/social/**").permitAll()

Swagger 테스트

카카오 access_token으로 가입 후, 다시 로그인을 했을 때 정상적으로 로그인이 되고 토큰을 발급받는것을 확인할 수 있습니다.

이로써 소셜 인증을 통한 가입 및 로그인 기능도 추가하였습니다. 다른 Oauth연동도 이와 거의 유사하므로 이후 다른 Social Provider 로그인 연동시 더 쉽고 빠르게 적용할 수 있을 것입니다.

최신 소스는 GitHub 사이트를 참고해 주세요. https://github.com/codej99/SpringRestApi/tree/feature/social-kakao

GitHub Repository를 import하여 Intellij 프로젝트를 구성하는 방법은 다음 포스팅을 참고해주세요.

Docker로 개발 환경을 빠르게 구축하는 것도 가능합니다. 다음 블로그 내용을 읽어보세요!

스프링 api 서버를 이용하여 웹사이트를 만들어보고 싶으시면 아래 포스팅을 참고해 주세요.

페이스북, 구글과 같은 OAuth2 서버를 개발하고 싶으시면 다음 포스트를 참고해 주세요.

연재글 이동[이전글] SpringBoot2로 Rest api 만들기(9) – Spring Starter Unit Test
[다음글] SpringBoot2로 Rest api 만들기(11) – profile을 이용한 환경별 설정 분리