OAuth2 + JWT 인증/인가 기능 구현

이동영·2025년 11월 12일

웹개발

목록 보기
23/36

내가 이번에 구현할 서버의 인증 흐름은 다음과 같다.

  1. 프론트(Next.js): /oauth2/authorization/google로 로그인 요청
  2. 백엔드(Spring Security) : 사용자를 구글 로그인 페이지로 보냄
  3. 사용자 : 구글 로그인 성공
  4. 백엔드(Callback) : 구글이 code를 우리 서버로 보내줌
  5. 백엔드(AuthService) : code로 사용자 정보를 받아와 DB에 저장/업데이트(loadOrRegisterUser)
  6. 백엔드(SuccessHandler) : JwtTokenProvider를 호출해 JWT (티켓) 발급
  7. 백엔드(Redirect) : 프론트 주소(localhost:3000)로 리디렉션하며 token=...을 전달
  8. 프론트(API요청) : 발급받은 JWT를 Authorization: Bearer ... 헤더에 담아 API 요청
  9. 백엔드(JwtFilter) : 토큰을 검문/검증하여 userId를 컨트롤러에 전달

0단계 : OAuth2 설정

application.properties에 OAuth2 설정을 추가한다.
그런데 나는 이번 프로젝트를 깃허브에 public으로 올리고 있어서, gitignore 된 application-oauth.properties를 생성하여 추가하였다.

# ======== OAuth2 Client 설정 ========

# --- Google (구글) ---
spring.security.oauth2.client.registration.google.client-id=[구글 Client ID 입력]
spring.security.oauth2.client.registration.google.client-secret=[구글 Client Secret 입력]
# Google은 'openid, profile, email'이 기본 scope라 별도 설정이 필요 없을 수 있음. 만약 필요하면 아래 주석 해제
# spring.security.oauth2.client.registration.google.scope=profile,email


# --- Facebook (페이스북) ---
spring.security.oauth2.client.registration.facebook.client-id=[메타 Client ID 입력]
spring.security.oauth2.client.registration.facebook.client-secret=[메타 Client Secret 입력]
# (Spring 기본 scope: public_profile, email)
# 프로필 사진(picture)을 가져오기 위해 user-info-uri를 재정의함
spring.security.oauth2.client.provider.facebook.user-info-uri=https://graph.facebook.com/me?fields=id,name,email,picture.type(large)


# --- Naver (네이버) ---
# Naver는 Spring Boot 기본 제공자가 아니므로, provider 정보까지 모두 입력해야 함
spring.security.oauth2.client.registration.naver.client-id=[네이버 Client ID 입력]
spring.security.oauth2.client.registration.naver.client-secret=[네이버 Client Secret 입력]
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code

# (TODO : 배포할 때 localhost 주소를 도메인으로 바꿔야함)
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image

# Provider 설정 (네이버 전용)
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
# Naver 응답 JSON이 { "resultcode": "00", "message": "success", "response": { ... } } 형태이므로,
# 실제 사용자 정보가 있는 'response' 객체를 user-name-attribute로 지정해야 함
spring.security.oauth2.client.provider.naver.user-name-attribute=response


# --- Kakao (카카오) ---
# Kakao도 Spring Boot 기본 제공자가 아니므로, provider 정보까지 모두 입력해야 함
spring.security.oauth2.client.registration.kakao.client-id=[카카오 Client ID 입력]
spring.security.oauth2.client.registration.kakao.client-secret=[카카오 Client secret 입력]
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code

# (TODO : 배포할 때 localhost 주소를 도메인으로 바꿔야함)
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,profile_image
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post

# Provider 설정 (카카오 전용)
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
# Kakao의 고유 ID(PK)는 'id' 필드
spring.security.oauth2.client.provider.kakao.user-name-attribute=id


# ======== JWT (Json Web Token) 설정 ========
jwt.secret-key=[64Byte 이상의 secret key 임의 입력]

# 만료시간 / 24시간 * 60분 * 60초 * 1000ms = 86400000
jwt.token-validity-in-milliseconds=86400000

Client ID/Secret를 받는 방법은 다음과 같다.

1. Google (구글)

  • 포털 : Google Cloud Console (구글 클라우드 콘솔)

  • 발급 경로 :
    1. 새 프로젝트를 생성한다.
    2. API 및 서비스 > OAuth 동의 화면으로 이동하여 앱 이름, 이메일 등 기본 정보를 등록한다.
    3. API 및 서비스 > 사용자 인증 정보로 이동한다.
    4. 사용자 인증 정보 만들기 > OAuth 클라이언트 ID를 선택한다.
    5. 애플리케이션 유형을 "웹 애플리케이션"으로 선택합니다.
    6. "승인된 리디렉션 URI"에 리디렉션 할 주소(e.g., http://localhost:8080/login/oauth2/code/google)를 추가한다. (서버 배포할 때에는 http://localhost:8080 대신 실제 도메인 주소로 바꿔서 추가해야 함)
    7. 생성 버튼을 누르면 Client ID/Scret이 발급된다.

2. Facebook (페이스북)

  • 포털 : Meta for Developers (Meta 개발자 포털)

  • 발급 경로 :
    1. 로그인 후 내 앱 > 앱 만들기를 선택한다.
    2. 앱 유형으로 '비즈니스' 또는 '소비자' 등을 선택하고 기본 정보를 입력한다.
    3. 앱 대시보드가 열리면, 왼쪽 메뉴에서 앱 설정 > 기본 설정으로 이동한다.
    4. 여기에 "앱 ID (App ID)" (Client ID)와 "앱 시크릿 (App Secret)" (Client Secret)을 받아온다.
    5. 왼쪽 메뉴 제품 > Facebook 로그인 > 설정으로 이동하여 유효한 OAuth 리디렉션 URI.../facebook 주소를 등록한다.

3. Kakao (카카오)

  • 포털 : Kakao Developers (카카오 개발자 센터)

  • 발급 경로 :
    1. 로그인 후 내 애플리케이션 > 애플리케이션 추가하기를 선택한다.
    2. 앱 정보를 입력하고 애플리케이션을 생성한다.
    3. 앱 설정 > 요약 정보 탭으로 이동하여 REST API 키를 받아온다. 이것이 Client ID이다.
    4. 왼쪽 메뉴 제품 설정 > 카카오 로그인으로 이동하여 활성화 설정을 ON으로 변경한다.
    5. Redirect URI 항목에 .../kakao 주소를 등록한다.
    6. 제품 설정 > 카카오 로그인 > 보안 탭으로 이동한다.
    7. Client Secret 항목에서 발급 버튼을 누르면 Client Secret 코드가 발급된다.

4. Naver (네이버)

  • 포털 : Naver Developers (네이버 개발자 센터)

  • 발급 경로 :
    1. 로그인 후 Application > 애플리케이션 등록을 선택한다.
    2. 앱 이름 등을 입력하고 사용 API에서 네이버 로그인을 선택한다. (필요한 정보: 이름, 이메일, 프로필 사진)
    3. 로그인 오픈 API 서비스 환경에서 PC 웹을 선택하고, 서비스 URL (예: http://localhost:8080)과 "Callback URL" (.../naver)을 등록한다.
    4. 등록이 완료되면 내 애플리케이션앱 상세 정보에서 Client ID/Secret 코드를 받아온다.

1단계 : OAuth2 로그인 처리 (DB 저장)

1. OAuthAttributes(DTO) : 응답 규격 통일

구글, 카카오, 네이버, 페이스북은 모두 JSON 응답 구조가 다르다.

  • 구글 PK : sub
  • 카카오 PK : id
  • 네이버 정보 : response 객체 안에 중첩되어 있음
  • 페이스북 사진 : picture.data.url안에 중첩되어 있음

OAuthAttributes라는 DTO를 만들어, 제각각인 JSON을 표준화된 (provider, oauthId, email, name, ...)객체로 파싱했다.

// OAuthAttributes.java
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
    if ("naver".equals(registrationId)) {
        return ofNaver(userNameAttributeName, attributes);
    }
    if ("kakao".equals(registrationId)) {
        return ofKakao(userNameAttributeName, attributes);
    }
    if ("facebook".equals(registrationId)) {
        return ofFacebook(userNameAttributeName, attributes);
    }
    return ofGoogle(userNameAttributeName, attributes);
}

// 카카오 파싱 예시
private static OAuthAttributes ofKakao(...) {
    Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
    String email = null;
    String nickname = null;
    
    if (kakaoAccount.containsKey("email")) {
        email = (String) kakaoAccount.get("email");
    }
    // ...
    return OAuthAttributes.builder()
            .name(nickname)
            .email(email)
            // ...
            .build();
}

2. AuthService : 사용자 저장/업데이트

loadOrRegisterUser라는 핵심 메소드를 만들었다. OAuthAttributes를 받아 UserRepository를 뒤져보고, 기존 회원이라면 User 엔티티의 name이나 profileImage를 업데이트한다. (@Transactional의 더티 체킹 활용)
신규회원이라면 OAuthAttributes.toEntity()를 호출해 User 엔티티를 새로 save()한다.

2단계 : JWT 발급 및 리디렉션 (성공 핸들러)

로그인 성공 직후의 로직은 다음과 같다.

1. JwtTokenProvider : 토큰 생성기

jjwt라이브러리를 사용해 토큰을 생성한다. application-oauth.properties에서 jwt.secret-key(Base64 인코딩된 비밀 키)와 만료 시간을 주입받는다.

// JwtTokenProvider.java
public String createToken(Authentication authentication) {
    // ...
    UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
    Long userId = userPrincipal.getUserId();

    return Jwts.builder()
            .setSubject(userId.toString())
            .claim("userId", userId)
            .claim("auth", authorities) // (예: "ROLE_USER")
            .signWith(key, SignatureAlgorithm.HS512)
            .setExpiration(validity)
            .compact();
}

여기서 보통 email로 설정하지만, 나는 email을 선택적으로 받아오게 해서, 사용자가 동의하지 않으면 null일 수가 있다. 따라서 토큰의 Subjectnull이 되지 않을 userId로 설정했다.

2. OAuth2LoginSuccessHandler : 성공 처리기

SecurityConfig에 등록된 이 핸들러가 모든 것을 처리한다.

// OAuth2LoginSuccessHandler.java
@Override
public void onAuthenticationSuccess(...) {
    
    // 1. Spring Security가 준 인증 정보(Principal)를 가져옴
    OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
    OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;

    // 2. 공급자("kakao")와 PK 필드명("id")을 동적으로 가져옴
    String providerId = oauthToken.getAuthorizedClientRegistrationId();
    ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(providerId);
    String userNameAttributeName = clientRegistration.getProviderDetails()...getUserNameAttributeName();

    // 3. 'OAuthAttributes'로 파싱
    OAuthAttributes attributes = OAuthAttributes.of(providerId, userNameAttributeName, oAuth2User.getAttributes());

    // 4. 'AuthService'로 DB에 저장/업데이트
    User user = authService.loadOrRegisterUser(...);

    // 5. 'UserPrincipal' (우리가 만든 인증 객체) 생성
    UserPrincipal userPrincipal = new UserPrincipal(user);

    // 6. 'JwtTokenProvider'로 JWT 발급
    String jwtToken = jwtTokenProvider.createToken(createAuthentication(userPrincipal));

    // 7. JWT를 담아 프론트엔드('localhost:3000')로 리디렉션
    String targetUrl = UriComponentsBuilder.fromUriString(FRONTEND_REDIRECT_URL)
            .queryParam("token", jwtToken)
            .build().toUriString();
            
    getRedirectStrategy().sendRedirect(request, response, targetUrl);
}

3단계 : JWT 검증 (API 검문)

이제 프론트엔드는 API를 요청할 때마다 헤더에 Authorization: Bearer <JWT>를 담아 보낸다.

Spring Security의 UsernamePasswordAuthenticationFilter 앞에 우리가 만든 JwtAuthenticationFilter를 배치했다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	//...
    // --------------- JWT 필터 등록
    // Spring Security의 기본 인증 필터보다 직접 만든 JWT 필터를 먼저 실행하도록 순서를 지정
    http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final JwtTokenProvider jwtTokenProvider;

    /**
     * 실제 필터링 로직: 요청마다 JWT 토큰을 검사함
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        // Request Header에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 토큰 유효성 검증
        // (StringUtils.hasText: null, "", " "가 아닌지 확인)
        if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {

            // 토큰이 유효하면, 토큰에서 Authentication(인증 정보) 객체를 가져옴
            Authentication authentication = jwtTokenProvider.getAuthentication(jwt);

            // SecurityContextHolder에 인증 정보를 저장
            // (이 코드가 실행되면, Spring Security는 이 요청을 '인증된 사용자'로 간주)
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

    /**
     * Request Header에서 "Bearer " 접두사를 제거하고 토큰 값만 추출
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7); // "Bearer " (7글자) 이후의 토큰 반환
        }
        return null;
    }
}

이 필터는 요청을 가로채서 헤더의 토큰을 검증(jwtTokenProvider.validateToken())하고, 유효하다면 JwtTokenProvider.getAuthentication()을 호출한다.

// JwtTokenProvider.java
/**
 * JWT 토큰의 유효성을 검증
 */
public boolean validateToken(String token) {
    try {
        jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
        log.info("잘못된 JWT 서명입니다.");
    } catch (ExpiredJwtException e) {
        log.info("만료된 JWT 토큰입니다.");
    } catch (UnsupportedJwtException e) {
        log.info("지원되지 않는 JWT 토큰입니다.");
    } catch (IllegalArgumentException e) {
        log.info("JWT 토큰이 잘못되었습니다.");
    }
    return false;
}

getAuthentication()메소드는 토큰의 Payload에서 userId: 55번을 꺼내, Long 타입 55를 SecurityContextHolder에 저장한다.

// JwtTokenProvider.java
public Authentication getAuthentication(String token) {
    Claims claims = Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();

    Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get("auth").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());

    // Payload에서 userId 가져오기
    Long userId = claims.get("userId", Long.class);

    // UserPrincipal 대신 userId를 principal로 사용 (컨트롤러에서 @AuthenticationPrincipal Long userId로 받기 위함)
    return new UsernamePasswordAuthenticationToken(userId, token, authorities);
}

JwtAuthenticationFilteruserId(Long 타입)를 저장해 둔 덕분에, 컨트롤러는 @AuthenticationPrincipal Long userId 어노테이션 한 줄로 현재 로그인한 사용자의 ID(55)를 주입받을 수 있다.

// AuthController.java
@GetMapping("/api/auth/me")
public ResponseEntity<UserResponseDto> getMyInfo(@AuthenticationPrincipal Long userId) {
    // ...
    UserResponseDto userInfo = authService.getUserInfo(userId);
    return ResponseEntity.ok(userInfo);
}

삽질 로그 (내가 만난 오류들)

이론은 간단했지만, 실제로는 수많은 오류와 싸워야 했다.

  1. authorization_request_not_found (카카오/네이버):

    • 원인 : STATELESS 설정 때문에, Spring Security가 로그인 요청 정보를 임시 저장할 세션이 없어서 발생.
    • 해결 : HttpCookieOAuth2AuthorizationRequestRepository를 구현하여, 세션 대신 임시 쿠키에 요청 정보를 저장하도록 SecurityConfig를 수정했다.
  2. 401 [no body] (카카오):

    • 원인 : Spring Boot가 카카오(커스텀 제공자)에 대한 토큰 요청 시 기본값(client_secret_basic - 헤더 전송)을 사용하여, 카카오가 요구하는 client_secret_post (바디 전송) 방식과 불일치했다.
    • 해결 : application-oauth.propertiesclient-authentication-method=client_secret_post를 명시적으로 추가하여 해결했다. 이 부분을 해결하는 데 오래 걸렸는데, Gemini가 이 부분을 명시 안 하면 자동으로 body 전송이 된다고 우겨서, header 방식으로 명시했다가 풀었다가 반복만 시켜서다. AI를 계속 맹신했으면 더 삽질했을 뻔.
  3. NullPointerException (카카오):

    • 원인 : 401 해결 후, 사용자가 카카오에서 '프로필 정보(닉네임)' 제공에 동의하지 않자, OAuthAttributes.ofKakaonullprofile 객체에 접근하려다 오류가 발생.
    • 해결 : ofKakao() 메소드에 profile 객체와 email이 null일 수 있음을 대비하는 null 체크(방어 코드)를 추가했다.
  4. Invalid Scopes: email (페이스북):

    • 원인 : 페이스북은 email 권한을 "고급 액세스"로 분류하여 별도 승인이 필요했습니다.
    • 해결 : 페이스북 개발자 포털의 앱 검수 > 권한 및 기능 메뉴에서 email 항목의 '고급 액세스 권한 받기'를 활성화했다.

ClassCastException (Google OIDC):

원인: 구글(OIDC) 로그인은 Spring의 기본 OidcUserService를, 카카오 등은 우리가 만든 CustomOAuth2UserService를 타려고 하면서 인증 객체(Principal) 타입이 충돌했다.

해결: CustomOAuth2UserService를 삭제했다. 대신, OAuth2LoginSuccessHandler가 Spring 기본 인증 객체(OAuth2User)를 받은 뒤, AuthService를 호출하여 DB에 저장/조회하는 방식으로 로직을 성공 핸들러로 이전시켰다.

0개의 댓글