내가 만들려는 GitHub OAuth 흐름이다.

백엔드에서 GitHub 인증을 통해 회원가입 진행 및 로그인이 되길 원했다.

이 과정을 기록하고자 한다.

 

1. GitHub App 생성

New OAty App 버튼 클릭

 

필수 입력 진행

 

작성이 완료되면 하단 Register Application 버튼 클릭

 

Generate a new client secret 버튼 클릭해서 secret key를 생성한다.

 

그리고 생성된 키를 복사해서 기억해두는 것이 좋다.

 

 

2. gradle 추가

    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'

현재 프로젝트에서 추가해야할 것만 추가했다.

 

3. SecurityConfiguration 수정

// 생략

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(csrf -> csrf.disable())
                .headers(headers -> headers.frameOptions(frame -> frame.disable()))
                .formLogin(form -> form.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                // 생략
                                new AntPathRequestMatcher("/login/oauth2/**"),
                                new AntPathRequestMatcher("/oauth2/**"),
                                // 생략
                        ).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/api/auth/signout")).authenticated()
                        .anyRequest().authenticated()
                )
                .httpBasic(Customizer.withDefaults())
                .addFilterBefore(
                        new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService),
                        UsernamePasswordAuthenticationFilter.class
                )
                .addFilterAfter(sentryUserContextFilter, JwtAuthenticationFilter.class);

        return http.build();
    }

// 생략

 

사용자가 로그인 버튼을 클릭하면, /oauth2/authorization/{provider} 경로를 통해 인증 요청이 시작된다.

이후 Spring Security 내부적으로 /login/oauth2/code/{provider} 경로로 콜백을 처리한다.

이 모든 과정은 사용자가 아직 로그인되지 않은 상태에서 이루어지기 때문에, 인증 없이 접근이 가능해야 하기 때문에 Spring Security에서 OAuth2 로그인 경로를 permitAll()로 설정했다.

 

4. domain 추가

// 생략
public class OauthAccount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 소셜 로그인 제공자 (github, google 등)
    @Column(name = "provider", nullable = false)
    private String provider;

    // provider가 부여한 고유 사용자 ID
    @Column(name = "provider_user_id", nullable = false)
    private String providerUserId;

    // 생성 시 자동 기록
    @CreationTimestamp
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_oa_user"))
    private User user;

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
    }
}

 

사용자(User)는 하나 이상의 소셜 계정으로 로그인할 수 있어야 한다.

예를 들어, 같은 사용자가 GitHub과 Google 계정을 모두 연동할 수 있는 구조를 고려해야 한다.

이를 위해 User와 1:N 관계를 갖는 OauthAccount 도메인을 설계했다.

  • id: OauthAccount의 기본 키. DB에서 자동 증가되는 값으로 관리
  • provider: OAuth2 제공자 이름을 저장
  • providerUserId: 소셜 로그인 제공자가 발급한 사용자 고유 ID
  • user
    • OauthAccount는 반드시 하나의 User와 연결되어야 함.
    • fetch = LAZY를 사용하여 실제로 접근할 때만 연관된 사용자 정보를 가져옴
    • 외래키 이름은 fk_oa_user로 명시하여 가독성과 유지보수성을 높임

 

5. DTO 추가

// 기본 사용자 정보 응답
@Getter
@Setter
@AllArgsConstructor
public class GithubUserResponse {
    private long id;
    private String login;
    private String name;
    private String email;

    @JsonProperty("avatar_url")
    private String avatarUrl;
}


// 추가 이메일 정보 응답
@Data
public class GithubEmailResponse {
    private String email;
    private Boolean primary;
    private Boolean verified;
    private String visibility;
}

GitHub OAuth2 로그인 과정에서 이메일 정보가 null인 경우, /user/emails API를 통해 보완해야 한다.

이를 위해 GithubEmailResponse DTO를 별도로 설계하여 응답 값을 명확히 매핑하고, 이후 사용자 식별 및 회원가입 로직에 활용했다.

 

6. Repository 추가

@Repository
public interface OauthAccountRepository extends JpaRepository<OauthAccount, Long> {
    Optional<OauthAccount> findByProviderAndProviderUserId(String provider, String providerUserId);
    boolean existsByProviderAndProviderUserId(String provider, String providerUserId);
}

OauthAccountRepository는 소셜 로그인 사용자 식별의 핵심 도구이다.
OAuth 제공자에서 전달받은 provider + providerUserId 조합을 기반으로 우리 시스템의 사용자와 연결하거나, 신규 회원가입 여부를 판단하는 데 사용한다.

  • findByProviderAndProviderUserId
    • 소셜 로그인 시 사용자가 기존에 등록된 사용자인지 조회할 때 사용
  • existsByProviderAndProviderUserId
    • 회원가입 전에 동일한 소셜 계정이 이미 존재하는지 검증할 때 사용

 

7. Serivce 추가

GitHub OAuth2 로그인 흐름을 실제로 처리하는 핵심 비즈니스 로직은 OAuthService에 구현했다.
전체 흐름은 다음과 같다:

  1. 프론트엔드에서 전달받은 code를 기반으로 access token을 요청하고
  2. access token으로 사용자 정보를 조회
  3. 이메일이 누락된 경우에는 /user/emails API를 통해 이메일을 추가 조회
  4. 기존 사용자 여부를 판단해 로그인 또는 회원가입 처리
  5. access & refresh token을 발급하여 반환
// 생략

    @Transactional
    public SignInResponse processGitHubLogin(String code, HttpServletResponse servletResponse) {
        String accessToken = getGitHubAccessToken(code);

        GithubUserResponse githubUser = getGitHubUserInfo(accessToken);

        // 이메일이 null인 경우 별도 API로 가져오기
        if (!StringUtils.hasText(githubUser.getEmail())) {
            String email = getGitHubUserEmail(accessToken);
            githubUser.setEmail(email);
        }

        // 이메일이 여전히 없으면 에러
        if (!StringUtils.hasText(githubUser.getEmail())) {
            throw new GlobalException(ErrorCode.OAUTH_EMAIL_NOT_FOUND);
        }

        Optional<OauthAccount> existingOAuth = oauthAccountRepository.findByProviderAndProviderUserId("github", String.valueOf(githubUser.getId()));

        User user;
        if (existingOAuth.isPresent()) {
            user = existingOAuth.get().getUser();
        } else {
            user = handleNewGitHubUser(githubUser);
        }

        return generateTokenResponse(user, servletResponse);
    }

// 생략

이 메서드는 GitHub 로그인 전체 프로세스를 처리한다.
코드를 기반으로 access token을 먼저 발급받고, 그 토큰을 사용하여 사용자 정보를 요청한다.
GitHub의 특성상 사용자 이메일이 null로 반환될 수 있기 때문에, 이 경우 /user/emails API를 별도 호출하여 이메일을 보완했다.

 

// 생략

    private String getGitHubAccessToken(String code) {
        String tokenUrl = "https://github.com/login/oauth/access_token";

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.set("Accept", "application/json");

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("client_id", githubClientId);
        params.add("client_secret", githubClientSecret);
        params.add("code", code);
        params.add("redirect_uri", githubRedirectUri);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

        try {
            ResponseEntity<TokenResponse> response = restTemplate.postForEntity(tokenUrl, request, TokenResponse.class);

            if (response.getBody() == null || response.getBody().getAccessToken() == null) {
                throw new GlobalException(ErrorCode.OAUTH_TOKEN_ERROR);
            }

            return response.getBody().getAccessToken();
        } catch (Exception e) {
            log.error("GitHub 액세스 토큰 요청 실패", e);
            throw new GlobalException(ErrorCode.OAUTH_TOKEN_ERROR);
        }
    }

    // 생략

GitHub의 OAuth2 토큰 발급 API (/login/oauth/access_token)를 호출하여 access token을 획득한다.
요청 시 Accept: application/json 헤더를 반드시 포함해 JSON 형식의 응답을 받을 수 있도록 설정했다.

 

// 생략
    private GithubUserResponse getGitHubUserInfo(String accessToken) {
        String userUrl = "https://api.github.com/user";

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);
        headers.set("Accept", "application/vnd.github.v3+json");

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

        try {
            ResponseEntity<GithubUserResponse> response = restTemplate.exchange(userUrl, HttpMethod.GET, request, GithubUserResponse.class);

            if (response.getBody() == null) {
                throw new GlobalException(ErrorCode.OAUTH_USER_INFO_ERROR);
            }

            return response.getBody();
        } catch (Exception e) {
            log.error("GitHub 사용자 정보 요청 실패", e);
            throw new GlobalException(ErrorCode.OAUTH_USER_INFO_ERROR);
        }
    }

// 생략

https://api.github.com/user API를 호출하여 사용자 정보를 조회한다.
여기서 id, login, name, avatar_url 등을 받을 수 있지만, email 필드는 종종 null일 수 있다.

 

    // 새로 추가된 메서드: GitHub 이메일 정보 가져오기
    private String getGitHubUserEmail(String accessToken) {
        String emailUrl = "https://api.github.com/user/emails";

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);
        headers.set("Accept", "application/vnd.github.v3+json");

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

        try {
            ResponseEntity<List<GithubEmailResponse>> response = restTemplate.exchange(
                    emailUrl,
                    HttpMethod.GET,
                    request,
                    new ParameterizedTypeReference<List<GithubEmailResponse>>() {}
            );

            if (response.getBody() == null || response.getBody().isEmpty()) {
                log.warn("GitHub 이메일 정보를 가져올 수 없습니다.");
                return null;
            }

            // 우선순위: primary이면서 verified인 이메일 > primary인 이메일 > verified인 이메일 > 첫 번째 이메일
            List<GithubEmailResponse> emails = response.getBody();

            // 1순위: primary이면서 verified
            Optional<GithubEmailResponse> primaryVerified = emails.stream()
                    .filter(email -> Boolean.TRUE.equals(email.getPrimary()) && Boolean.TRUE.equals(email.getVerified()))
                    .findFirst();

            if (primaryVerified.isPresent()) {
                return primaryVerified.get().getEmail();
            }

            // 2순위: primary
            Optional<GithubEmailResponse> primary = emails.stream()
                    .filter(email -> Boolean.TRUE.equals(email.getPrimary()))
                    .findFirst();

            if (primary.isPresent()) {
                return primary.get().getEmail();
            }

            // 3순위: verified
            Optional<GithubEmailResponse> verified = emails.stream()
                    .filter(email -> Boolean.TRUE.equals(email.getVerified()))
                    .findFirst();

            if (verified.isPresent()) {
                return verified.get().getEmail();
            }

            // 4순위: 첫 번째 이메일
            return emails.get(0).getEmail();

        } catch (Exception e) {
            log.error("GitHub 이메일 정보 요청 실패", e);
            return null;
        }
    }

사용자의 이메일 정보를 가져오기 위한 메서드다.
/user/emails API를 호출하여 이메일 목록을 받아온 뒤, 아래와 같은 우선순위 로직으로 가장 적절한 이메일을 선택한다:

  1. primary && verified == true
  2. primary == true
  3. verified == true
  4. 첫 번째 이메일

이메일이 완전히 누락된 경우 예외를 던져 회원가입을 막는다.

 

// 생략

    private User handleNewGitHubUser(GithubUserResponse githubUser) {
        Optional<User> existingUser = userRepository.findByEmail(githubUser.getEmail());

        User user;
        if (existingUser.isPresent()) {
            user = existingUser.get();
        } else {
            user = createNewUserFromGitHub(githubUser);
        }

        OauthAccount oauthAccount = OauthAccount.builder()
                .provider("github")
                .providerUserId(String.valueOf(githubUser.getId()))
                .user(user)
                .build();

        oauthAccountRepository.save(oauthAccount);

        return user;
    }

// 생략

해당 이메일로 가입된 사용자가 이미 있다면 그대로 사용하고, 없을 경우 새로 회원을 생성한다.
이때 OauthAccount도 함께 저장하여 소셜 계정과 사용자를 연결한다.

 

// 생략
    private User createNewUserFromGitHub(GithubUserResponse githubUser) {
        String nickname = userService.generateUniqueNickname(NicknameGenerator.generate());

        String profileImageUrl = profileImageService.generateProfileImageUrl(nickname, 48);

        User user = User.builder()
                .username(githubUser.getName() != null ? githubUser.getName() : githubUser.getLogin())
                .nickname(nickname)
                .email(githubUser.getEmail())
                .phoneNumber("")
                .password("")
                .profileImageUrl(profileImageUrl)
                .emailVerified(true) // GitHub에서 인증된 이메일로 간주
                .build();

        return userRepository.save(user);
    }
// 생략

신규 유저를 생성할 때는 다음과 같은 규칙을 적용했다:

  • username: GitHub name → 없으면 login
  • nickname: 랜덤 생성 + 중복 방지 (NicknameGenerator)
  • profileImageUrl: 닉네임 기반 자동 생성 이미지
  • 비밀번호/전화번호는 빈 문자열로 설정
  • 이메일 인증은 GitHub에서 verified된 것으로 간주하고 emailVerified = true 처리

 

// 생략

    @Transactional
    public SignInResponse generateTokenResponse(User user, HttpServletResponse servletResponse) {
        String accessToken = jwtTokenProvider.createToken(user.getId());
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());

        refreshTokenService.save(user.getId(), refreshToken);

        Cookie refreshCookie = new Cookie("refreshToken", refreshToken);
        refreshCookie.setHttpOnly(true);
        refreshCookie.setPath("/");
        refreshCookie.setMaxAge(60 * 60 * 24 * 14); // 2주
        refreshCookie.setSecure(true); // SameSite=None 사용 시 필수
        servletResponse.addCookie(refreshCookie);

        // SameSite=None 설정을 위한 Set-Cookie 헤더 추가
        servletResponse.setHeader(
                "Set-Cookie",
                "refreshToken=" + refreshToken + "; HttpOnly; Secure; Path=/; Max-Age=" + (60 * 60 * 24 * 14) + "; SameSite=None"
        );

        return new SignInResponse(accessToken, new SignInUserDto(user));
    }

// 생략

최종적으로 access token과 refresh token을 발급하고 반환한다.
refresh token은 HttpOnly, Secure, SameSite=None 옵션으로 쿠키에 담아 반환한다.
이와 동시에 Set-Cookie 헤더를 명시적으로 설정해 크로스 도메인 환경에서도 쿠키가 정상적으로 동작하도록 처리했다.

 

// 생략

    private static class TokenResponse {
        @JsonProperty("access_token")
        private String access_token;

        @JsonProperty("token_type")
        private String token_type;

        @JsonProperty("scope")
        private String scope;

        public String getAccessToken() { return access_token; }
        public void setAccessToken(String access_token) { this.access_token = access_token; }
        public String getTokenType() { return token_type; }
        public void setTokenType(String token_type) { this.token_type = token_type; }
        public String getScope() { return scope; }
        public void setScope(String scope) { this.scope = scope; }
    }

// 생략

 

8. Controller 추가

GitHub OAuth2 로그인 요청이 완료된 후, GitHub는 code 파라미터를 포함한 콜백 요청을 백엔드로 보낸다.
이 콜백을 처리하는 역할은 OAuthController에서 담당한다

// 생략

    @GetMapping("/github/callback")
    public void githubCallback(
            @RequestParam("code") String code,
            HttpServletResponse response) throws IOException {

        try {
            SignInResponse signInResponse = oAuthService.processGitHubLogin(code, response);
            sendSuccessResponse(response, signInResponse);
        } catch (Exception e) {
            sendErrorResponse(response, e.getMessage());
        }
    }

// 생략

GitHub OAuth 인증이 완료되면 사용자는 이 엔드포인트로 리디렉션된다.
이때 전달된 code를 바탕으로 실제 로그인 처리를 수행하는 서비스(OAuthService.processGitHubLogin)를 호출한다.

 

성공응답

// 생략

    private void sendSuccessResponse(HttpServletResponse response, SignInResponse data) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        String jsonData = objectMapper.writeValueAsString(data);

        response.setContentType("text/html; charset=UTF-8");
        PrintWriter out = response.getWriter();

        String html = String.format("""
            <!DOCTYPE html>
            <html>
            <head><title>GitHub Login Success</title></head>
            <body>
                <script>
                    console.log('GitHub 로그인 성공');
                    if (window.opener) {
                        window.opener.postMessage({
                            type: 'GITHUB_LOGIN_SUCCESS',
                            response: %s
                        }, 'http://localhost:5173');
                        window.close();
                    }
                </script>
                <p>로그인 성공! 창이 자동으로 닫힙니다.</p>
            </body>
            </html>
            """, jsonData);

        out.println(html);
        out.flush();
    }

// 생략

 

  • 응답 데이터를 JSON 문자열로 직렬화
  • HTML 문서 내 <script>에서 postMessage로 프론트엔드에 로그인 결과 전달
  • 이후 자동으로 팝업 창을 닫음
  • Content-Type을 text/html; charset=UTF-8로 명시하여 한글 깨짐 방지

 

 

실패 응답

// 생략

    private void sendErrorResponse(HttpServletResponse response, String errorMessage) throws IOException {
        response.setContentType("text/html; charset=UTF-8");
        PrintWriter out = response.getWriter();

        String html = String.format("""
            <script>
                if (window.opener) {
                    window.opener.postMessage({
                        type: 'GITHUB_LOGIN_ERROR',
                        error: '%s'
                    }, 'http://localhost:5173');
                    window.close();
                }
            </script>
            """, errorMessage);

        out.println(html);
        out.flush();
    }

// 생략

 

  • 예외 발생 시 에러 메시지를 포함한 GITHUB_LOGIN_ERROR 메시지를 postMessage로 전송
  • 동일하게 창을 자동으로 닫음

이제 프론트 작업이다.

사용자가 로그인 버튼을 클릭하면, 백엔드의 인증 URL로 새로운 팝업을 띄우고, 로그인 완료 시 postMessage로 결과를 수신받는다

// 생략

  const handleClickGithubLogin = () => {
    const popup = window.open(
      GITHUB_LOGIN_URL,
      'GithubLoginPopup',
      `width=${width},height=${height},left=${left},top=${top},resizable=no,scrollbars=yes,status=no`
    );

    if (!popup) {
      alert('팝업이 차단되었습니다. 브라우저 설정을 확인해주세요.');
      return;
    }

    const handleMessage = (event: MessageEvent) => {
      if (event.origin !== 'http://localhost:8080') return;

      const { type, response } = event.data;
      if (type === 'GITHUB_LOGIN_SUCCESS') {
        setAuthSocialLogin(response);
        popup?.close();

        window.removeEventListener('message', handleMessage);
      } else if (type === 'GITHUB_LOGIN_ERROR') {
        alert('GitHub 로그인에 실패했습니다.');
        popup?.close();
        window.removeEventListener('message', handleMessage);
      }
    };

    window.addEventListener('message', handleMessage);

    // 팝업이 닫혔는지 확인 (사용자가 직접 닫은 경우)
    const checkClosed = setInterval(() => {
      if (popup.closed) {
        clearInterval(checkClosed);
        window.removeEventListener('message', handleMessage);
      }
    }, 1000);
  };

// 생략

 

 

  • window.open()으로 OAuth 인증용 팝업을 띄움
  • 인증 완료 후, 백엔드에서 팝업 창 내 postMessage()로 결과 전송
  • 메인 창에서는 window.addEventListener('message')를 통해 결과 수신
  • 수신 데이터의 type에 따라 로그인 성공 또는 실패 처리
  • 사용자가 직접 팝업을 닫은 경우를 대비해 setInterval()로 닫힘 여부 확인

사실 처음엔 팝업 생각을 하지 못했다.

그냥 버튼 클릭 시 깃헙 입증이 되도록 url 요청을 보내는걸로 만들었는데

새로운 탭에 인증을 진행하면서 인증 완료된 이후 데이터를 가져오지 못하는 문제가 발생했다.

그러던 중 깃북에서 팝업을 통해 깃헙입증을 하는 것을 보고 팝업으로 수정하게 되어 위와 같은 코드가 만들어졌다.

전체 코드는 BE GitHub & FE GitHub 에서 확인이 가능하다.

 

+ Recent posts