GitHub OAuth2 로그인과 JWT 인증 통합하기
소셜 로그인을 도입한 이유
블로그에 댓글 기능을 추가하면서 인증 방식을 고민했다. 선택지는 세 가지였다.
1. 자체 회원가입
- 이메일/비밀번호 기반 가입
- 이메일 인증, 비밀번호 찾기 등 부가 기능 필요
- 사용자 입장에서 "또 다른 계정"을 만들어야 함
2. 익명 댓글
- 가입 없이 닉네임만 입력
- 스팸 관리가 어려움
- 사용자 추적 불가
3. 소셜 로그인
- 기존 계정(GitHub, Google 등)으로 간편 로그인
- 이메일 인증 불필요
- 신뢰할 수 있는 사용자만 유입
관리자 계정의 경우에는 이메일/비밀번호 기반 가입으로 구현했지만, 무분별한 계정 생성을 막고 보안을 강화하기 위해 OAuth 를 선택했다. 또한 개발 블로그 특성상 방문자 대부분이 개발자일 거라 판단했다. GitHub 계정은 개발자라면 거의 가지고 있고, GitHub 로그인 하나면 충분할 것 같았다. Google이나 카카오를 추가하지 않은 건 대상 사용자가 명확했기 때문이다.
두 가지 OAuth 구현 방식
OAuth 로그인을 구현하는 방식은 크게 두 가지가 있다.
방식 1: 서버 주도 리다이렉트
Spring Security OAuth2 Client의 기본 방식이다. 백엔드가 전체 OAuth 흐름을 제어한다.
1. 프론트: /oauth2/authorization/github 링크 클릭
2. 백엔드: GitHub 로그인 페이지로 리다이렉트
3. GitHub: 인증 후 백엔드 콜백 URL로 리다이렉트
4. 백엔드: OAuth2SuccessHandler에서 JWT 생성
5. 백엔드: 프론트 URL로 리다이렉트 (?accessToken=xxx&refreshToken=xxx)
장점은 Spring Security가 대부분 처리해준다는 것이다. 단점은 토큰이 URL query parameter에 노출된다는 점과, 리다이렉트 과정에서 사용자 경험이 다소 끊기는 느낌이 있다.
방식 2: 프론트 주도 API 호출
프론트엔드가 OAuth 흐름을 제어하고, 백엔드는 토큰 교환만 담당한다.
1. 프론트: GitHub 로그인 팝업 또는 새 창 열기
2. GitHub: 인증 후 프론트 콜백 URL로 리다이렉트 (authorization code 포함)
3. 프론트: 받은 code를 백엔드 API로 POST
4. 백엔드: code로 GitHub API 호출하여 사용자 정보 조회
5. 백엔드: JWT 생성 후 JSON 응답으로 반환
이 방식을 채택했다. 이유는 다음과 같다.
프론트 주도 방식을 선택한 이유
1. 토큰이 URL에 노출되지 않는다
API 응답 body로 토큰을 받기 때문에 브라우저 히스토리나 서버 로그에 토큰이 남지 않는다. 리다이렉트 방식은 ?accessToken=xxx가 URL에 노출되어 보안상 찝찝했다.
2. 프론트엔드에서 UX 제어가 쉽다
팝업으로 로그인 창을 띄우면 사용자가 현재 페이지를 벗어나지 않는다. 로그인 성공 후 팝업만 닫히고 메인 페이지가 갱신되는 자연스러운 흐름이 가능하다.
전체 인증 흐름

핵심은 프론트가 GitHub에서 받은 authorization code를 백엔드 API로 전달하는 것이다. 백엔드는 이 code로 GitHub API를 호출해서 사용자 정보를 가져오고, 자체 JWT를 발급한다.
OAuthController: code를 JWT로 교환
프론트에서 받은 authorization code를 처리하는 API다.
POST /*/github/callback
Request Body: { "code": "authorization_code_from_github" }
Response: { "accessToken": "xxx", "refreshToken": "xxx" }
내부 처리 흐름:
- GitHub token endpoint에 code를 보내서 GitHub access token을 받는다
- GitHub user API를 호출해서 사용자 정보(id, email, name 등)를 조회한다
- provider + providerId로 기존 사용자인지 확인한다
- 없으면 새 사용자를 생성하고, 있으면 정보를 업데이트한다
- 자체 JWT(Access Token + Refresh Token)를 생성한다
- Refresh Token을 Redis에 저장한다
- 토큰을 JSON 응답으로 반환한다
Spring Security의 OAuth2 Client를 사용하지 않고 RestTemplate으로 GitHub API를 직접 호출한다. OAuth2 Client는 세션 기반으로 동작하는 부분이 많아서, JWT 기반 인증과 깔끔하게 통합하기 어려웠다.
사용자 식별 전략: provider + providerId
OAuth 로그인 시 사용자를 어떻게 식별하고 저장할지 결정해야 했다.
이메일만으로 식별하면 생기는 문제
처음에는 이메일로 사용자를 구분하려 했다. GitHub에서 받은 이메일이 이미 가입된 이메일이면 기존 계정으로 로그인하고, 아니면 새 계정을 생성하는 방식이다.
하지만 이 방식에는 문제가 있다.
- 같은 이메일, 다른 사람: GitHub와 Google에서 같은 이메일을 쓰는 서로 다른 사람이 있을 수 있다
- 이메일 변경: 사용자가 GitHub 이메일을 변경하면 새 계정으로 인식된다
- 이메일 비공개: GitHub는 이메일을 비공개로 설정할 수 있다
provider + providerId 조합
채택한 방식은 provider(GitHub, Google 등)와 providerId(해당 서비스의 고유 ID)를 조합해서 식별하는 것이다.
User 엔티티:
- id (PK)
- email
- name
- provider ← "github", "google" 등
- providerId ← GitHub의 사용자 ID (숫자)
- role
GitHub의 사용자 ID는 숫자로 된 고유 식별자다. 이메일이 변경되어도, 계정을 삭제하고 다시 만들지 않는 한 변하지 않는다.
사용자 조회 로직은 이렇게 된다.
1. provider="github", providerId="12345678" 로 사용자 조회
2. 있으면: 기존 사용자로 로그인, 이메일/이름 등 최신 정보로 업데이트
3. 없으면: 새 사용자 생성 (기본 역할: USER)
이메일은 여전히 저장하지만 식별용이 아니라 표시용이다. 프로필에서 이메일을 보여주거나, 알림을 보낼 때 사용한다.
겪었던 문제들
GitHub 이메일 비공개 설정
GitHub에서 이메일을 비공개로 설정한 사용자가 로그인하면 이메일이 null로 온다. 처음에는 이메일 필수 검증 로직 때문에 로그인이 실패했다.
해결 방법으로 GitHub API에서 제공하는 {id}+{username}@users.noreply.github.com 형태의 noreply 이메일을 사용하도록 했다. 어차피 이메일로 알림을 보내지 않으니 표시용으로만 쓰면 된다.
GitHub OAuth App 콜백 URL 설정
GitHub OAuth App 설정에서 Authorization callback URL을 프론트엔드 URL로 지정해야 한다. 백엔드 URL로 지정하면 프론트 주도 방식이 동작하지 않는다.
개발 환경과 운영 환경의 콜백 URL이 다르기 때문에, GitHub OAuth App을 환경별로 분리하거나 여러 콜백 URL을 등록해야 했다.
현재 구조의 장단점
장점
- 토큰 노출 최소화: URL이 아닌 API 응답으로 토큰 전달
- 인증 체계 단일화: OAuth든 일반 로그인이든 결과는 JWT
- 확장 용이: Google, Kakao 등 추가 시 같은 패턴으로 구현
단점
- 직접 구현 필요: Spring Security OAuth2 Client의 편의 기능을 못 씀
- GitHub API 직접 호출: RestTemplate으로 token endpoint, user endpoint 호출
- 단일 OAuth 제공자: GitHub만 지원해서 비개발자 접근 어려움