[Spring] 카카오 소셜 로그인 구현하기

ssunn·2025년 1월 13일

spring

목록 보기
1/2

약 두 달 전에 싸피에서 최종 관통 프로젝트(이하 에코백) 때 소셜 로그인 기능을 구현했었다.
당시에 프로젝트로 너무 바빠서 노션에 대충 정리하고 말았는데, 공부한게 아깝고 제대로 기록해두고 싶어서 블로그에 정리하게 되었다.
우리는 시간상 카카오 소셜 로그인만 구현했고, 추후 구글/네이버/애플 등 타사 소셜 로그인 도입을 하기로 했다. (관통 끝나고 방학 때 시도하려고 했는데...... 그렇게 됐다;;)

소셜 로그인 기능 구현에 앞서 사용자 정보 인증/인가 프로세스를 먼저 정리했다. 에코백 서비스에서는 JWT를 사용해서 서비스 자체 access tokenrefresh token을 만들어 관리했다. 그리고 Interceptor와 ArgumentResolver를 활용해서 클라이언트로부터 사용자 인증 정보를 주고 받는 프로세스를 관리했다. 이 모든 과정을 먼저 정리해둔 다음에 카카오 소셜 로그인 기능을 구현했다.

access token과 refresh token의 동작 프로세스

이 내용은 사용자 정보 관리를 위한 JWT를 도입해본 사람이라면 다 알 기본적인 내용이지만 그만큼 매우 중요한 내용이기 때문에 한번 더 정리해보겠다.

사용자 로그인 요청

요청한 사용자 정보가 존재하는 경우 비밀번호 일치 여부를 확인한다.
비밀번호가 일치한다면 refresh tokenaccess token을 발급한다.
일치하지 않는다면 로그인 요청에 실패했음을 응답 메시지로 넘긴다.

요청한 사용자 정보가 존재하지 않는 경우 사용자 정보가 없는 것으로 로그인 요청에 실패했음을 응답 메시지로 넘긴다.

사용자 인증

모든 사용자 요청에 대해 사용자 유효성 검증을 진행해야한다.
물론 로그인, 회원가입과 같은 사용자 정보가 없어도 동작해야하는 기능에 대해서는 유효성 검증을 하지 않는다. 외에도 로그인 없이 사용할 수 있는 기능들에 대해서는 유효성 검증을 하지 않아도 된다. 에코백 서비스의 경우 커스텀 어노테이션을 만들어 유효성 검증을 진행해야하는 모든 엔드포인트에서 Interceptor에서 유효성 검증을 진행했다.

이쯤되면 의문이 생길 수도 있다. 왜 Spring Security 냅두고 Interceptor 설정해서 굳이 커스텀 어노테이션에 리졸버까지 만드는 번거로운 일을 한거지?
Spring Security에서는 Filter를 사용해서 여러 엔드포인트에 대한 설정을 추가할 수 있는 것으로 알고 있다. 나는 이번 로그인 기능 구현에서 사용자 인증이 필요한 경우를 Filter가 아닌 Interceptor를 활용하려 했고, 그 이유는 사용자 인증 성공 시에 토큰을 까서 사용자 정보를 비즈니스 로직에 넘겨주는 것을 한번에 처리하고 싶었기 때문이다. 에코백은 규모가 크지 않은 서비스였고, 또 사용자 인증이 필요한 기능이 생각보다 적었기 때문에 Interceptor에서 한번에 처리해도 괜찮을 것이라 판단했다.
물론! Filter 써서 인증 관리 한번 더 해서 걸러내면 보다 꼼꼼하게 보안 처리를 할 수 있을 것이다.
(그리고 Spring Security를 쓰지 않은 결정적인 이유는.... 안 써봐서 공부해야되는데 2주 안에 프로젝트 완성하기엔 시간이 부족해서였다,,)

로그아웃

사용자가 로그아웃 요청을 하면 쿠키에 저장된 refresh token을 무효화하고 db에 저장된 refresh token을 삭제한다. 그냥 저장된 refresh token 정보를 다 삭제하는 것이다.

*쓰고 나서 생각하니 로그아웃 부분도 뭔가 보완할 수 있는 부분이 있지 않을까 싶다. 굉장히 많은 사용자가 사용하는 서비스라고 가정했을 때 한꺼번에 많은 사용자가 로그아웃 요청을 보내면 부하가 발생할 것 같다. 이 문제를 해결하기 위한 방법도 생각해보면 좋을 것 같다.

Interceptor와 ArgumentResolver를 활용한 사용자 인증/인가 프로세스

Interceptor

인터셉터의 역할은 요청이 컨트롤러에 도달하기 전에 또는 응답이 사용자에게 반환되기 전에 요청/응답을 가로채서 공통적인 로직을 처리하는 것이다. 즉, 사용자 유효성 검증을 진행한다.
인터셉터는 로직 처리 위치를 지정할 수 있다.

  1. preHandle: 컨트롤러에 요청이 전달되기 전에 실행
  2. postHandle: 컨트롤러에서 처리된 결과가 View로 전달되기 전에 실행
  3. afterCompletion: View 처리가 완료된 후 실행

사용자 유효성 검증, 인증/인가 확인 등도 할 수 있지만 외에 로깅 및 분석, 공통 헤더 추가/수정, 글로벌 전처리/후처리 작업 등을 진행할 수 있다.
추후에 유효성 검증을 Filter에서 처리하게 된다면 Interceptor는 주로 로깅/분석에 사용할 것 같다.

ArgumentResolver

컨트롤러의 메서드 매개변수를 동적으로 생성하거나 변환해 매핑할 수 있다.
만약 컨트롤러의 메서드 매개변수로 사용자 정보에 대한 dto인 UserRequestDto가 넘어왔다고 치자.
UserRequestDto에는 비즈니스 로직에서 알 필요 없는 정보들(token 정보, token 만료 정보 등등)이 있어 이것들을 걸러내는 작업이 필요하다. 이 때, 이 로직을 모든 엔드포인트에서 수행한다고 생각하면... 최대한 효율적으로 처리하고 싶을 것이다.
이 때 사용하는 것이 ArgumentResolver이다. 에코백의 경우 이것을 커스텀 어노테이션으로 만들어 사용자 유효성 검증이 필요한 엔드포인트에 붙여 수행했다.

	// 회원 정보 조회
    @GetMapping
    public ResponseEntity<UserInfoReadResponse> readUserInfo(
            @Authenticated AuthenticatedUser authenticatedUser
    ) {
        log.info("userId: " + authenticatedUser.userId());
        final User user = userService.readDetail(authenticatedUser.userId());
        final UserInfoReadResponse response = UserInfoReadResponse.from(user);
        return ResponseEntity.ok(response);
    }

@Authenticated라는 커스텀 어노테이션을 활용해 ArgumentResolver에서 AuthenticatedUser 객체를 생성해 컨트롤러로 넘겨주면 컨트롤러에서는 AuthenticatedUseruserId 정보만 Service로 넘겨줄 수 있는 것이다.
이렇게 리졸버를 잘 활용하면 토큰에서 사용자 정보 까고, 사용자 정보에서 사용자 아이디 또 가져오는 프로세스를 깔끔하게 처리할 수 있다.

Interceptor와 ArgumentResolver를 활용한 인증/인가 프로세스

다음은 에코백 서비스의 InterceptorArgumentResolver를 활용한 인증/인가 프로세스이다.

  1. Interceptor: 요청/응답 흐름 제어

    • preHandle:

      1. 요청 헤더에서 Authorization(JWT 토큰) 확인

      2. 토큰 유효성 검사(만료 여부, 서명 검증)

      3. 유효한 경우, 인증된 사용자 정보를 HttpServletRequest의 속성에 저장

      4. 유효하지 않은 경우, 예외를 던지거나 401 상태 코드 반환

  2. ArgumentResolver: 사용자 객체 생성

    1) 컨트롤러 메서드에서 매개변수로 사용자 정보를 요청
    2) ArgumentResolver에서 @Authenticated 어노테이션 처리

    • HttpServletRequest에서 사용자 정보 추출(Interceptor에서 설정한 값)
    • 사용자 객체를 생성하거나 반환
    • supportsParameter()에서는 @Authenticated가 붙은 모든 파라미터에 대해 AuthenticatedArgumentResolver가 실행됨을 의미
    • resolveArgument()에서는 webRequestAUTH_AUTHORIZATION 헤더에서 토큰 정보를 가져와 토큰에서 userId 정보를 뽑아 AuthenticatedUser 객체로 반환한다.
      @Override
      public boolean supportsParameter(MethodParameter parameter) {
          return parameter.getParameterAnnotation(Authenticated.class)!=null;
      }

      @Override
      public Object resolveArgument(MethodParameter parameter,
                                    ModelAndViewContainer mavContainer,
                                    NativeWebRequest webRequest,
                                    WebDataBinderFactory binderFactory) {
          String authorizationHeader = webRequest.getHeader(HttpHeaders.AUTH ORIZATION);

          if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
              throw new InvalidTokenException();
          }

          String token = authorizationHeader.replace("Bearer ", "");
          String userId = jwtTokenProvider.getUserId(token);
          return new AuthenticatedUser(Long.parseLong(userId));
      }

카카오 소셜 로그인

이제 token의 흐름과 Interceptor, ArgumentResolver의 역할을 알았으니 카카오로부터 사용자 정보를 인가 받아 서비스 자체 인증 프로세스를 만들 준비가 모두 끝이 났다.

전체 흐름

kakao-social-login-process

  1. 사용자 인증 요청: 클라이언트가 카카오 로그인 페이지로 리디렉션
  2. Authorization Code 획득: 사용자가 로그인 후, 카카오에서 Authorization Code를 클라이언트로 반환
  3. Access Token 발급: 서버가 Authorization Code를 사용해 카카오로부터 Access Token을 요청
  4. 사용자 정보 요청: Access Token을 사용해 사용자 정보를 카카오 API로부터 획득
  5. 로컬 사용자 인증/인가 처리:
    • 기존 사용자라면 로그인 처리
    • 신규 사용자라면 회원가입 처리

구현 프로세스

1. 카카오 로그인 URL 생성

  • 클라이언트는 카카오 로그인 API URL로 리디렉션
  • 필요한 파라미터:
    • client_id: REST API 키
    • redirect_uri: 카카오 로그인 완료 후 반환될 서버의 URL
    • response_type=code: Authorization Code를 요청

예시:

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

2. Authorization Code 수신

  • 사용자가 로그인 완료 후, redirect_uriAuthorization Code가 전달됩니다.
  • 서버는 code 쿼리 파라미터를 읽어 처리:
    @PostMapping("/login/kakao")
    public ResponseEntity<LoginResponse> loginWithKakao(
            @RequestParam("code") String code,
            HttpServletResponse response
    ) {
        final KakaoTokensDto kakaoTokensDto = kakaoLoginService.getKakaoTokens(code);
        final KakaoUserInfo userInfo = kakaoLoginService.getUserInfo(kakaoTokensDto.accessToken());
        
        // 서비스 자체 refresh token 생성 로직

        return ResponseEntity.ok(loginResponse);
    }

    

3. Access Token 요청

Authorization Code를 사용해 Access Token 요청:

  • 요청 URL: https://kauth.kakao.com/oauth/token
  • 요청 방식: POST
  • 필수 파라미터:
    • grant_type=authorization_code
    • client_id: REST API 키.
    • redirect_uri: 동일한 리다이렉트 URI.
    • code: Authorization Code.
	public KakaoTokensDto getKakaoTokens(String code) {
        String url = accessTokenApi;  // OAuth 카카오 access token api

        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(url)
                                                              .queryParam("grant_type",
                                                                      "authorization_code")
                                                              .queryParam("client_id",
                                                                      kakaoLoginProperties.client_id())
                                                              .queryParam("redirect_uri",
                                                                      kakaoLoginProperties.redirect_uri())
                                                              .queryParam("code", code);

        final String clientSecret = kakaoLoginProperties.client_secret();
        if (clientSecret != null && !clientSecret.isEmpty()) {
            uriBuilder.queryParam("client_secret", clientSecret);
        }

        HttpHeaders headers = new HttpHeaders();
        HttpEntity<String> entity = new HttpEntity<>(headers);

        ResponseEntity<String> response = restTemplate.exchange(uriBuilder.toUriString(),
                HttpMethod.POST, entity, String.class);

        if (response.getStatusCode() == HttpStatus.OK) {
            return parseKakaoTokens(response.getBody());
        } else {
            throw new FailedToGetAccessTokenException();
        }
    }

	private KakaoTokensDto parseKakaoTokens(String response) {
        ObjectMapper objectMapper = new ObjectMapper();

        try {
            JsonNode jsonNode = objectMapper.readTree(response);

            final String accessToken = jsonNode.get("access_token").asText();
            final String refreshToken = jsonNode.get("refresh_token").asText();

            return KakaoTokensDto.of(accessToken, refreshToken);
        } catch (JsonProcessingException e) {
            throw new EcobagException(ExceptionMessage.RUNTIME_EXCEPTION);
        }
    }

4. 사용자 정보 요청

Access Token을 사용해 사용자 정보를 요청:

  • 요청 URL: https://kapi.kakao.com/v2/user/me
  • 요청 방식: GET
  • 헤더:
    • Authorization: Bearer {ACCESS_TOKEN}
	public KakaoUserInfo getUserInfo(String accessToken) {
        String url = userInfoApi;  // OAuth 카카오 get user info api

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);

        HttpEntity<String> entity = new HttpEntity<>(headers);

        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity,
                String.class);

        if (response.getStatusCode().is2xxSuccessful()) {
            return parseKakaoUserInfo(response.getBody());
        } else {
            throw new FaildToGetUserInfoException();
        }
    }

    private KakaoUserInfo parseKakaoUserInfo(String response) {
        ObjectMapper objectMapper = new ObjectMapper();

        try {
            JsonNode jsonNode = objectMapper.readTree(response);

            Long oauthId = jsonNode.get("id").asLong();
            String nickname = jsonNode.path("kakao_account")
                                      .path("profile")
                                      .get("nickname")
                                      .asText();
            String profileImage = jsonNode.path("kakao_account")
                                          .path("profile")
                                          .get("profile_image_url")
                                          .asText(null);

            return KakaoUserInfo.of(oauthId, nickname, profileImage);
        } catch (JsonProcessingException e) {
            throw new EcobagException(ExceptionMessage.RUNTIME_EXCEPTION);
        }
    }

5. 로컬 사용자 인증/인가 처리

  • 신규 사용자: 사용자 정보를 저장하고 로컬 데이터베이스에 회원가입 처리
  • 기존 사용자: 해당 정보를 조회하고 로그인 처리
  • 필수 정보: 사용자 식별 ID (id), 이메일 (kakao_account.email), 닉네임 (properties.nickname)

*카카오 소셜 로그인 기능을 구현하면서 api 통신을 위해 RestTemplate을 사용했다. 이 때 당시에는 제대로 이해하지 못하고 RestTemplate이 api 통신이 가능하대서 그냥 사용했는데 이후 WebClient, RestClient가 대용으로 나온 것을 알게 되었다. 다음에 이 내용들에 대해서 또 공부해서 포스트를 올릴 예정이다.

보완할 점

몇가지 아쉬운 점이 있었다.

  1. 여러 소셜 로그인 연동을 위한 인터페이스 제공
  2. token 정보 NoSQL로 관리하기
  3. 소셜 로그인 연동 해제하기

2주 간의 짧은 시간 동안 진행되었던 프로젝트인지라 새로 도전해보고 싶었던 기술들을 많이 도전하지 못했다. 그래도 카카오 소셜 로그인 기능은 프로세스 모두 제대로 이해해서 잘 구현한 것 같아 뿌듯했다. 그럼에도 여전히 적용하지 못해 아쉬웠던 부분에 대해서는 추후에라도 보완하고 싶다.

참고

REST API | Kakao Developers 문서 - 카카오
ChatGPT

1개의 댓글

comment-user-thumbnail
2025년 1월 23일

코드를 보고 이번 포스팅을 보니 더 이해가 잘되네요!! 잘 보고 갑니담.

답글 달기