GitHub OAuth2 소셜 로그인 구현하기(+Spring Security를 왜 써야할까?)

예름·2025년 5월 21일
14

Spring

목록 보기
3/3
post-thumbnail

📍 개요

OAuth2는 다양한 서비스(GitHub, Google, Kakao 등)의 인증 기능을 우리 서비스에 손쉽게 연동할 수 있게 해주는 인증 프레임워크입니다.

특히 Spring Security는 OAuth2 클라이언트 기능을 기본 지원하여 간단한 설정만으로 소셜 로그인을 빠르게 구현할 수 있습니다.

이 글에서는 GitHub 소셜 로그인을 예시로 전체 동작 흐름과 Spring Security 내부 동작, 그리고 실제 구현 코드까지 하나씩 살펴보겠습니다.


🔐 OAuth2 소셜 로그인이란?

OAuth2는 제3자 서비스(GitHub, Google, Kakao 등)에 로그인 권한을 위임하고, 이를 통해 사용자 인증을 처리하는 방식입니다.

즉, 사용자는 내 서비스에 직접 ID/PW를 입력하지 않고 이미 가입된 소셜 로그인 계정으로 로그인할 수 있습니다.

❓ OAuth2 소셜 로그인을 서비스에 도입하면 어떤 장점이 있을까요?

  • 비밀번호를 저장할 필요가 없어 보안성이 높습니다.
  • 사용자 입장에서 로그인 과정이 간편합니다.
  • 다양한 소셜 계정 연동이 쉬워 서비스 확장성도 뛰어납니다.

❓ OAuth2라면 OAuth1도 있나요?

네, 있습니다.

OAuth1은 과거에 사용되던 초기 버전입니다. 또한 아직 표준은 아니지만 OAuth3도 논의되고 있습니다.

각각의 특징에 대해서도 알아보겠습니다.

⌛️ OAuth1

  • 자체적으로 요청에 서명을 포함해서 보안성이 높습니다. (Replay attack 방지 등)
  • 너무 복잡해서 개발자들이 싫어합니다. (Signature 계산, 암호화 필요)

✅ OAuth2

  • 간단하고 유연합니다.
  • 다양한 흐름을 지원합니다. (Authorization Code, Client Credentials 등)
  • 모바일, 웹, 서버 앱 모두 대응 가능합니다.
  • 토큰 자체가 “자격 증명”이라서 Bearer Token 탈취 시 위험합니다. (따라서 HTTPS는 필수, 추가로 PKCE 같은 보완 기술 필요)

✨ OAuth3 (GNAP)

  • OAuth2의 단점을 보완하기 위한 후속 스펙입니다.
  • 사용자, 클라이언트, 권한 서버 간의 상호작용을 좀 더 구조화하고 보안을 강화했습니다.
  • 아직은 IETF Internet Draft 수준이라 공식 표준은 아닙니다.
  • 실사용보다는 연구 및 테스트 단계입니다.

🔁 OAuth2 소셜 로그인(Github) 동작 방식

OAuth2 소셜 로그인 방식을 시퀀스 다이어그램으로 표현하면 다음과 같습니다.

1. 사용자가 서비스 내에서 “Login with GitHub” 버튼을 클릭합니다.

2. 클라이언트(브라우저)는 GitHub의 인증 서버로 Authorization Request를 보냅니다.

요청에는 client_id, redirect_uri, scope, response_type=code 등이 포함됩니다.

3. GitHub는 사용자의 GitHub 로그인 페이지를 보여줍니다.

(이미 로그인된 상태라면 생략될 수도 있습니다)

4. 사용자가 로그인하고, 애플리케이션에게 권한을 부여(Authorize)합니다.

5. GitHub는 Authorization Code를 포함한 redirect 요청을 서비스에 전달합니다.

이때 요청은 미리 등록된 redirect_uri로 전송됩니다.

6. 클라이언트(또는 Spring Security)가 Authorization Code를 서버(백엔드)로 전달합니다.

밑에서 다루지만 Spring Security에서는 이 부분을 자동 처리합니다 (/login/oauth2/code/github).

7. 서버는 GitHub에 Access Token을 요청합니다.

이때 Authorization Code, client_id, client_secret, redirect_uri 등을 포함하여 POST 요청을 보냅니다.

8. GitHub는 Access Token을 응답합니다.

9. 서버는 이 Access Token을 사용하여 GitHub API에 사용자 정보를 요청합니다.

10. GitHub는 사용자 정보(JSON)를 응답합니다.

11. 서버는 사용자 정보를 바탕으로 회원가입 또는 로그인 처리를 수행한 후, JWT 또는 세션을 발급하고 클라이언트에게 인증 상태를 전달합니다.


🛡️ Spring Security를 이용한 OAuth2 소셜 로그인 동작 방식

이번에는 Spring Security를 이용한 OAuth2 소셜 로그인 방식을 알아보겠습니다.

얼핏 보면 위에서 설명한 OAuth2 기본 흐름보다 더 복잡해 보일 수 있지만, 실제로는 Spring Security가 대부분의 과정을 자동으로 처리해주기 때문에 훨씬 간단하게 구현할 수 있습니다.

기본적인 흐름은 앞에서 설명한 OAuth2와 동일합니다.
다만 Spring Security를 활용하면, 6~10번 단계에 해당하는 Access Token 요청, 사용자 정보 조회, 인증 객체 생성 등 일련의 과정을 프레임워크가 모두 대신 처리해줍니다.

구체적으로 Spring Security는 다음과 같은 과정을 자동으로 처리합니다

  1. 사용자가 GitHub 로그인 버튼을 클릭하면, Spring Security는 GitHub의 인증 서버로 Authorization 요청을 보냅니다.
  2. 사용자가 GitHub 로그인 및 권한 승인을 마치면 GitHub는 redirect_uri로 Authorization Code를 전달합니다.
  3. Spring Security는 이 코드를 받아 Access Token을 요청하고 응답을 받아냅니다.
  4. 받은 Access Token을 이용해 GitHub에 사용자 정보를 요청하고,
    응답받은 데이터를 바탕으로 OAuth2User 객체를 생성합니다.
  5. OAuth2User는 Spring Security의 인증 컨텍스트에 저장되며
    이후 컨트롤러에서는 @AuthenticationPrincipal을 통해 손쉽게 사용자 정보를 활용할 수 있습니다.

🔧 우리가 직접 구현해야 하는 부분은?

  • 어떤 OAuth2 제공자를 사용할지 설정 (ex. GitHub client-id, secret 등)
  • 사용자 정보를 가공하고 저장하는 CustomOAuth2UserService
  • 로그인 성공 이후 처리를 원하는 경우, SuccessHandler 커스터마이징

Spring Security를 활용하면 보안 처리와 표준 흐름을 직접 구현하지 않아도 되기 때문에,
복잡한 인증 과정을 단 몇 줄의 설정과 한두 개의 클래스 구현으로 손쉽게 구성할 수 있습니다.


🛠️ Spring Security를 이용한 OAuth2 소셜 로그인 구현 (코드 첨부)

개발 환경

Java 17
Spring Boot 3.4.6
MySQL
Redis

build.gradle

우선 build.gradle 파일에 아래 Spring Security와 OAuth2 의존성을 추가해주세요.

dependencies {

	...

    // security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'

    // oauth2
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    
    ...
}

GitHub OAuth App 등록

GitHub의 Settings > Developer settings > OAuth Apps 에 들어갑니다.


위와 같은 화면이 보이면 New OAuth app 버튼을 누릅니다.


위에 보이는 항목들을 채웁니다.


등록하면 다음과 같은 화면이 보이는데, Generate a new client secret 버튼을 눌러 Client secrets을 생성합니다.


해당 페이지를 나가면 secret key가 사라지기 때문에 복사를 해서 임시로 저장을 해둡니다.

Client ID와 Client secrets는 이후 application.yml 파일에 작성합니다.

  • Client ID: 공개 가능한 앱 식별자
  • Client Secret: 서버에서만 사용하는 비밀 키

🚨 주의! Client secrets는 절대 외부에 노출하면 안됩니다! (저는 앱을 삭제했습니다)

이제 밑으로 내리면 아래와 같이 입력하는 칸이 뜨는데, 초기에 입력했던 내용들과 추가로 Application logo를 설정할 수 있습니다.

이렇게 하면 GitHub에서 해야할 설정은 다 했습니다.

application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: YOUR_CLIENT_ID # GitHub OAuth Apps에서 발급해준 Client ID
            client-secret: YOUR_CLIENT_SECRET # GitHub OAuth Apps에서 발급해준 Client secrets
            redirect-uri: http://localhost:8080/login/oauth2/code/github # GitHub OAuth Apps에서 설정한 Authorization callback URL
            scope: read:user,user:email # 접근할 범위
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: login

application.yml 파일도 위와 같이 설정합니다.

provider: 아래 부분은 그대로 입력하면 됩니다.

SecurityConfig.java

Security Config부터 시작해서 SecurityFilterChain, OAuth2UserService 등등의 파일을 마주하면 멘붕이 오곤 합니다.

따라서 본격적으로 코드를 작성하기에 앞서, 앞으로 나오는 파일들의 역할을 짚고 넘어가겠습니다.


SecurityFilterChain이 요청 URL 패턴을 매칭, 내부적으로 OAuth2LoginAuthenticationFilter가 동작, 커스텀 OAuth2UserService를 통해 사용자 정보를 가져오고 성공/실패 핸들러가 실행되는 흐름입니다.

저는 클라이언트를 Thymleaf로 구현했기 때문에 Redis에 세션을 저장하는 방식을 택했기 때문에 위의 시퀀스 다이어그램에 Redis가 존재합니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserRepository userRepository;
    private final SessionManager sessionManager;
    private final RedisTemplate<String, String> redisTemplate;
    private final OAuth2AuthorizedClientService authorizedClientService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest()
                        .permitAll()
                )
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(oauth2UserService())
                        )
                        .successHandler(
                                authenticationSuccessHandler())
                        .failureHandler(authenticationFailureHandler())
                )
                .csrf(AbstractHttpConfigurer::disable)
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .addLogoutHandler(logoutHandler())
                        .logoutSuccessUrl("/")
                );

        return http.build();
    }

    @Bean
    public OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {

        return new CustomOAuth2UserService(userRepository);
    }

    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {

        return new CustomOAuth2SuccessHandler(userRepository, sessionManager, redisTemplate,
                authorizedClientService);
    }

    @Bean
    public AuthenticationFailureHandler authenticationFailureHandler() {

        return new CustomOAuth2FailureHandler();
    }

    @Bean
    public LogoutHandler logoutHandler() {

        return new CustomLogoutHandler(sessionManager);
    }
}

CustomOAuth2UserService.java

OAuth2UserService를 구현한 객체입니다.

OAuth2LoginAuthenticationFilter를 거치고 얻은 사용자 정보를 조회하여 사용자가 DB에 존재하지 않을 경우 저장한 후 OAuth2User 객체를 생성하여 반환합니다.

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oauth2User = delegate.loadUser(userRequest);

        // GitHub 사용자 정보 추출
        int githubId = oauth2User.getAttribute("id");
        String username = oauth2User.getAttribute("login");
        String avatarUrl = oauth2User.getAttribute("avatar_url");

        if (!userRepository.existsByGithubId(githubId)) {
            userRepository.save(new User(githubId, username, avatarUrl));
        }

        Map<String, Object> attributes = new HashMap<>(oauth2User.getAttributes());

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                attributes,
                "login" // GitHub에서 사용자 ID로 쓰이는 필드
        );
    }

}

CustomOAuth2SuccessHandler.java

인증을 성공했을 때 실행합니다.

User를 조회하여 세션을 생성한 후 Redis에 저장하도록 했습니다. 또한 github token도 Redis에 저장합니다.

@RequiredArgsConstructor
public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private final UserRepository userRepository;
    private final SessionManager sessionManager;
    private final RedisTemplate<String, String> redisTemplate;
    private final OAuth2AuthorizedClientService authorizedClientService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException {

        OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
        int githubId = oauth2User.getAttribute("id");

        User user = userRepository.findByGithubId(githubId)
                .orElseThrow(() -> new GeneralException(UserException.USER_NOT_FOUND));
        sessionManager.createSession(request, user.getId());

        OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
        String registrationId = oauthToken.getAuthorizedClientRegistrationId(); // client 이름

        OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
                registrationId,
                oauthToken.getName()
        );

        String accessToken = client.getAccessToken().getTokenValue();
        String redisKey = "github:token:" + githubId;
        redisTemplate.opsForValue().set(redisKey, accessToken, Duration.ofHours(1));

        response.sendRedirect("/login/success");
    }
}

로직이 끝나면 /login/success로 리다이렉트합니다.

CustomOAuth2FailureHandler.java

인증을 실패했을 때 실행합니다.

@Slf4j
public class CustomOAuth2FailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {

        if (exception.getMessage().contains("redirect")) {
            log.warn(new GeneralException(AuthException.INVALID_REDIRECT_URI).getMessage());
        } else if (exception.getMessage().contains("user cancelled")) {
            log.warn(new GeneralException(AuthException.OAUTH_USER_CANCELED).getMessage());
        } else {
            log.warn(new GeneralException(AuthException.OAUTH_PROVIDER_ERROR).getMessage());
        }

        response.sendRedirect("/login/failure");
    }
}

로직이 끝나면 /login/failure로 리다이렉트합니다.

CustomLogoutHandler.java

로그아웃 할 때 실행합니다.

logout 메서드는 Redis에 저장된 세션을 제거하는 로직입니다.

@RequiredArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {

    private final SessionManager sessionManager;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) {

        sessionManager.clearSession(request);
    }
}

SessionAuthenticationFilter.java

매 요청마다 통과하는 필터입니다. 하지만 로그인 전 초기 페이지나 로그아웃 페이지에는 세션(인증)이 존재하지 않기 때문에 이 필터를 지나가면 안됩니다.

따라서 shouldNotFilter 메서드를 통해 인증이 필요하지 않은 URI를 추가해줍니다.

🚨 주의! OAuth2LoginAuthenticationFilter와 다른 필터입니다!

OAuth2LoginAuthenticationFilter는 Spring Security가 OAuth2 로그인 처리를 위해 자동으로 등록하는 필터입니다.
저는 서비스가 세션 기반이라 SessionAuthenticationFilter를 작성했지만, JWT 기반이라면 JwtAuthenticationFilter를 커스텀해서 작성하면 됩니다!

@Slf4j
@Component
@RequiredArgsConstructor
public class SessionAuthenticationFilter extends OncePerRequestFilter {

    private final SessionManager sessionManager;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        Long userId = sessionManager.getUserId(request);
        if (userId == null) {
            response.sendRedirect("/login/failure");
            return;
        }

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {

        String path = request.getRequestURI();

        return PERMIT_ALL_PATHS.contains(path);
    }

    // TODO : 기능 추가되면 인증 필요없는 URI 추가
    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/", "/login/failure"
    );
}

GithubOAuth2WebClient.java

OAuth2로 연동한 서비스를 탈퇴할 때 실행합니다.

@Component
@RequiredArgsConstructor
public class GithubOAuth2WebClient {

    @Value("${spring.security.oauth2.client.registration.github.client-id}")
    private String clientId;

    private final WebClient githubRevokeWebClient;

    public void unlink(String oauthAccessToken) {

        if (!StringUtils.hasText(oauthAccessToken)) {
            throw new GeneralException(AuthException.EMPTY_OAUTH_TOKEN);
        }

        Map<String, String> body = Map.of("access_token", oauthAccessToken);

        githubRevokeWebClient
                .method(HttpMethod.DELETE)
                .uri("/applications/{clientId}/grant", clientId)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(body)
                .retrieve()
                .toBodilessEntity()
                .block();
    }
}

UserService에서 다음과 같이 사용됩니다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final GithubRepoRepository githubRepoRepository;
    private final NotificationAgreementRepository notificationAgreementRepository;
    private final RedisTemplate<String, String> redisTemplate;
    private final GithubOAuth2WebClient githubOAuth2WebClient;

    public User findById(Long userId) {

        return userRepository.findById(userId)
                .orElseThrow(() -> new GeneralException(UserException.USER_NOT_FOUND));
    }

    @Transactional
    public void deleteById(Long userId) {
    
    	// 생략
        
        String oauthAccessToken = redisTemplate.opsForValue()
                .get("github:token:" + user.getGithubId());

        githubOAuth2WebClient.unlink(oauthAccessToken); // GitHub OAuth2 앱에서 사용자 제거
        redisTemplate.unlink("github:token:" + user.getGithubId()); // 세션에 저장된 토큰 제거

        userRepository.deleteById(userId);
    }
}

⛓️ SecurityFilterChain

현재 구현한 Security FilterChain의 흐름은 다음과 같습니다.

요청
 ↓
FilterChainProxy 진입
 |── SecurityContextPersistenceFilter: 인증 정보 로드
 |── SessionAuthenticationFilter: 세션 인증 필요 시 작동 (커스텀 필터)
 |── OAuth2AuthorizationRequestRedirectFilter: /oauth2/authorization/** 경로일 때만 작동
 |── OAuth2LoginAuthenticationFilter: /login/oauth2/code/** 경로일 때만 작동
 |── ExceptionTranslationFilter
 |── FilterSecurityInterceptor: 인가 처리
 ↓
DispatcherServlet
 ↓
컨트롤러 (@Controller, @RestController)

Spring Security의 필터는 등록된 순서대로 정해진 체인에서 실행됩니다.
하지만 각 필터는 자신에게 해당하는 조건일 때만 작동하기 때문에, 모든 요청에서 모든 필터가 실행되는 것은 아닙니다.

예를 들어, OAuth2LoginAuthenticationFilter/login/oauth2/code/** 경로에만 작동하며,
UsernamePasswordAuthenticationFilter는 일반 폼 로그인을 사용할 때만 작동합니다.

또한, 커스텀 필터를 직접 추가하고 순서를 조정하고 싶다면
SecurityConfig에서 addFilterBefore, addFilterAfter, addFilterAt 등을 사용해 제어할 수 있습니다.

아래는 서비스 전반적인 흐름입니다.

HTTP 요청
↓
[FilterChainProxy]
|── SecurityContextPersistenceFilter
|── Authentication filters (e.g., OAuth2, JWT)
|── Authorization (FilterSecurityInterceptor)
 ↓
[DispatcherServlet]
|── HandlerMapping
|── Controller 실행
|── ViewResolver
↓
HTTP 응답

🚨 주의!

FilterChainProxyDispatcherServlet 이전에 실행되기 때문에 아래와 같은 GlobalExceptionHandler에서 예외를 잡을 수 없습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(GeneralException.class)
    public ResponseEntity<ExceptionResponse> handleGeneralException(GeneralException e) {

        return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e.getMessage()));
    }
}

추가 정보는 공식문서에서 확인하실 수 있습니다.

profile
안정적인 쳇바퀴를 돌리는 삶

2개의 댓글

comment-user-thumbnail
2025년 5월 22일

너무너무 필요한 내용이었어요~~
잘 보고 갑니다!😎

1개의 답글