🛡️ Spring Boot + OAuth2 + JWT + React 실전 이중 토큰 인증 프로젝트 정리
실무에서 많이 쓰는 JWT 이중 토큰 기반의 로그인/회원가입/소셜 로그인(구글, 네이버) 풀스택 프로젝트 기록
실무에서 많이 쓰는 JWT 이중 토큰 기반의 로그인/회원가입/소셜 로그인(구글, 네이버) 풀스택 프로젝트 기록
목차
- 프로젝트 개요
- 전체 아키텍처 및 인증 플로우
- 기술 스택 및 환경
- JWT 책임에 관한 문제
- 백엔드(Spring Boot) 구현
- 프론트엔드(React) 구현
- OAuth2 소셜 로그인 상세 플로우
- 이중 토큰 관리 및 주의점
- 개발 과정중 에러/트러블슈팅
- 회고 및 느낀 점
이 프로젝트의 기본 구조 및 JWT/OAuth2 로그인 시스템 개념은 다음 두 블로그 글을 참고하였습니다.
구조 설계/시스템 통합 및 프론트엔드(React) 전체 구현은 웹서핑, ChatGPT 등 다양한 자료를 참고하여 직접 구현하였으며, 기본 개념/구조는 참고 자료를 바탕으로 실전 프로젝트 수준으로 커스텀 및 통합하였습니다.
프로젝트 개요
- 목표: 실무에서 바로 쓸 수 있는 JWT 기반 이중 토큰(Access, Refresh) 인증 + 소셜 로그인(구글, 네이버) + 일반 로그인 환경을 직접 구축
- 배경: REST API, SPA(React) 환경에서 인증/보안 문제를 경험하며, 실무에서 쓰는 OAuth2/JWT 패턴을 직접 구현
- 학습포인트:
- 인증 플로우 전체 구조 파악
- 프론트-백엔드간 토큰, 쿠키, CORS 실전 적용
- 단일토큰이 아닌 이중토큰을 사용하여 자동 토큰 재발급, 소셜 로그인, 사용자 정보 조회 등
- Redis DB를 활용
전체 아키텍처 및 인증 플로우
- 일반 로그인 + OAuth2로그인 아키텍처 다이어그램

📝 일반 로그인
- /login(또는 /join) API로 ID/PW POST
- 백엔드 인증 성공시
- AccessToken을 응답 헤더(access)에 실어서 내려줌
- RefreshToken을 httpOnly 쿠키로 내려줌
- 프론트엔드는
- 응답 헤더에서 accessToken 추출 → localStorage에 저장
- refreshToken은 브라우저 쿠키에 자동 저장(JS 접근 불가)
- 이후 모든 인증 API 요청
- headers에 accessToken을
"access"
키로 포함해 전달
- accessToken 만료시
- 프론트가
/reissue
로 쿠키(refreshToken)를 자동 전송해 accessToken 재발급
- 로그아웃시
- refreshToken(쿠키) 필요, 서버에서 즉시 삭제
🔗 소셜 로그인(OAuth2)
- 프론트에서 소셜 로그인(네이버/구글) 버튼 클릭
/oauth2/authorization/naver|google
로 이동(하이퍼링크)
- Provider(네이버/구글) 인증 후
- 백엔드로 인가코드 전달 → 회원가입/로그인 처리
- 로그인 성공시
- RefreshToken만 httpOnly 쿠키로 내려줌
- (AccessToken은 일단 헤더에 담기지만, 리다이렉트라 JS로 읽을 수 없음)
- 프론트로
/oauth2/redirect
로 리다이렉트
- 프론트는 리다이렉트된 후
- 즉시
/api/token
(또는 /my
)로 fetch/POST(쿠키: refreshToken만 보유)
- 백엔드가 refreshToken을 검증해 accessToken을 헤더에 실어서 응답
- 프론트가 응답 헤더에서 accessToken을 추출해 localStorage에 저장
- 이후 인증 API 요청
- headers에 accessToken을
"access"
키로 포함해 전달
⚠️ 공통 유의사항
- CORS 설정, withCredentials: true 필수
- accessToken: localStorage, refreshToken: httpOnly 쿠키(브라우저만 접근)
- 모든 인증 관련 요청에는 accessToken을 헤더로, refreshToken은 쿠키로 자동 전송
기술 스택 및 환경
- 백엔드: Spring Boot, Spring Security, OAuth2 Client, JWT, Redis
- 프론트: React (Context API, Axios), CSS-in-JS
- DB/스토리지: MySQL(유저), Redis(RefreshToken)
- 기타: Postman
JWT 책임에 관한 문제
❓ 고민의 시작
JWT 기반 인증 시스템을 직접 설계/구현하며 가장 크게 고민한 부분은
- "실제로 무엇을 어디까지 백엔드가 책임져야 하는가?"
- "단일토큰 대비 이중토큰 설계가 어떤 의미에서 더 안전한가?"
였습니다.
🔒 단일 토큰의 한계
- AccessToken 하나로만 인증하는 경우, 토큰 유출(탈취)시 누구나 장기간 불법 사용 가능
- 만료 기간을 짧게 하면 사용성(UX) 저하, 길게 하면 보안 저하
- 프론트/브라우저에 저장되는 토큰의 노출 가능성 (localStorage, sessionStorage, XSS 등)
- 만료 후엔 자동 재발급이 어려워 UX/보안 둘 다 잡기 어려움
🔑 이중 토큰(AccessToken + RefreshToken) 도입 이유
-
AccessToken:
- 유효기간 짧게(10~30분), 탈취돼도 피해 최소화
- 프론트(localStorage 등)에 저장
-
RefreshToken:
- HttpOnly 쿠키 + Redis 관리(노출 차단)
- 유효기간 김(1~2주), 주기적 교체/블랙리스트/로그아웃 시 즉시 만료 (추가 예정)
- JS에서는 접근 불가, 자동 인증 요청에만 사용
-
만료 시나리오:
- AccessToken 만료 → 프론트가 refreshToken(쿠키)로
/reissue
API 호출
- 서버에서 Redis에 저장된 refresh와 쿠키 값을 비교 후 access 재발급 → 프론트에 전달
- refreshToken도 주기적으로 교체, 로그아웃/블랙리스트 등 서버에서 강제 만료 가능
⚙️ 모든 책임을 백엔드가 지도록 설계
- 토큰 검증, 만료, 재발급, 로그아웃/블랙리스트 모두 백엔드(Spring)에서 처리
- 프론트는 오로지 accessToken 저장/전송 역할
- refreshToken은 절대 JS에서 접근 불가(HttpOnly 쿠키), 백엔드만 관리
🌐 OAuth2 소셜 로그인에서의 추가 고민
- Spring Security의 OAuth2 인증은 리다이렉트(하이퍼링크) 방식
- 기존처럼 accessToken을 헤더로 내려주면, 브라우저가 리다이렉트 과정에서 헤더값을 읽지 못함
👉 해결 방식
- 소셜 로그인 성공시 refreshToken만 HttpOnly 쿠키에 실어서 내려줌
- 프론트는 리다이렉트된 후
/api/token
(또는 /my
)로 POST
- 백엔드는 쿠키의 refreshToken을 검증, accessToken을 새로 만들어 헤더로 내려줌
- 프론트는 이 accessToken을 localStorage 등에 저장, 이후 인증 요청 때 사용
✅ 이 방식의 장점
- 소셜 로그인/일반 로그인 모두 동일한 이중토큰 패턴으로 통일
- 모든 토큰 관리 책임이 백엔드에 집중 → 보안성과 유지보수 용이성 증가
- 프론트는 accessToken 저장/전송만 책임, 나머지 인증 관련 로직은 서버 전담
백엔드(Spring Boot) 구현
📁 트리
main
│ ├── controller
│ │ ├── JoinController.java
│ │ ├── MainController.java
│ │ ├── MyController.java
│ │ ├── ReissueController.java
│ │ └── TokenController.java
│ ├── dto
│ │ ├── GoogleResponse.java
│ │ ├── JoinDTO.java
│ │ ├── LoginDTO.java
│ │ ├── NaverResponse.java
│ │ ├── OAuth2Response.java
│ │ ├── UserDTO.java
│ │ └── UserPrincipal.java
│ ├── entity
│ │ └── UserEntity.java
│ ├── jwt
│ │ ├── JWTFilter.java
│ │ ├── JWTUtil.java
│ │ └── LoginFilter.java
│ ├── oauth2
│ │ └── CustomSuccessHandler.java
│ ├── repository
│ │ └── UserRepository.java
│ └── service
│ ├── CustomLogoutFilter.java
│ ├── CustomOAuth2UserService.java
│ ├── CustomUserDetailsService.java
│ ├── JoinService.java
│ └── RefreshTokenService.java
⚙️ 주요 설정/로직
- CORS:
localhost:3000
허용, withCredentials: true, 허용 헤더/노출 헤더 세팅
- JWT 발급/파싱: JWTUtil에서 생성/검증/만료 등 전담
- OAuth2 로그인: CustomSuccessHandler에서 로그인 성공시 refresh(쿠키)로 분리 발급
- RefreshToken 관리: Redis 저장/검증/삭제, 만료/로그아웃시 삭제
- 필터/인가정책: JWTFilter에서 헤더에 access가 없으면 거부, 카테고리 검증, 만료시 401
🛡️ API 예시
POST /join
(회원가입)
POST /login
(일반 로그인 / 소셜 로그인)
POST /reissue
(토큰 재발급)
POST /logout
(로그아웃)
GET /my
(로그인 사용자 정보 조회)
💡 예외처리/보안
- 인증 없는 요청은 permitAll, 나머지 authenticated
- 인증 만료/잘못된 토큰/소셜 최초 로그인 등 세분화된 에러 메시지 처리 (추후 예정)
프론트엔드(React) 구현
📁 파일 트리
src/
├─ api/axiosConfig.js
├─ contexts/AuthContext.js
├─ pages/Home.js, Login.js, Join.js, MyPage.js, OAuth2Redirect.js
├─ components/NavBar.js
├─ routes/MyRoutes.js
└─ App.js
🌐 핵심 기능
- Context API로 전역 인증상태/유저 정보/토큰 관리
- accessToken은 localStorage, refreshToken은 쿠키로 자동 처리
- axios/fetch 기본값에 withCredentials: true 필수
- 자동 토큰 재발급/만료시 자동 로그아웃 및 UX 분기
- 권한 페이지, 비로그인 페이지, 예외처리/리다이렉트 분기
OAuth2 소셜 로그인 상세 플로우
🔗 플로우 요약
- 사용자가 "네이버/구글 로그인" 버튼 클릭
- 프론트엔드에서
/oauth2/authorization/provider
(네이버/구글)로 이동 (window.location.href)
- Provider(구글/네이버)에서 인증 후, 백엔드 콜백(
/login/oauth2/code/{provider}
)으로 인가코드 전송
- 백엔드에서 인가코드로 사용자 정보 획득 → 자체 회원가입/로그인 처리
- CustomSuccessHandler에서
- refreshToken: httpOnly 쿠키로 발급
- /oauth2/redirect로 프론트엔드 리다이렉트
- 프론트엔드는 리다이렉트 후,
POST /api/token
등 인증 API를 쿠키만 가지고 바로 호출
- 백엔드가 쿠키(refresh)로 accessToken 재발급 → 응답 헤더에서 accessToken 저장
- 이후 인증 API 요청시 항상 accessToken을 헤더에, refreshToken은 쿠키로 사용
💡 핵심 구현 포인트
- accessToken이 반드시 응답 헤더로만 오기 때문에, JS로 직접 받으려면 추가 fetch(POST) 필요
- refreshToken은 httpOnly 쿠키이므로 JS에서는 못 읽음 → 인증 API 호출에만 자동 첨부됨
- 리다이렉트 후
/api/token
등 안전한 추가 fetch 필수
📈 플로우차트 예시
- 일반로그인 JWT 인증 아키텍처 다이어그램

- OAuth2 인증 아키텍처 다이어그램

이중 토큰 관리 및 주의점
🛡️ 이중 토큰 관리란?
- AccessToken: 만료 짧음(5~15분), 인증 헤더로 전송, localStorage 등 클라이언트 저장
- RefreshToken: 만료 김(1~2주), httpOnly 쿠키 + 서버(Redis 등) 저장, JS에서 읽기 불가, 자동 인증만 가능
⚠️ 주의할 점/실무 체크리스트
- RefreshToken 쿠키:
- 반드시 httpOnly + Secure, SameSite 옵션 필요(배포 환경)
- 서버에서도 Redis 등 별도 저장 후 검증/삭제
- AccessToken 관리:
- localStorage XSS 위험 주의(필요 최소화)
- 만료시 자동 재발급/재로그인 UX 명확히 구현
- 로그아웃/탈취 시나리오:
- refreshToken은 블랙리스트/삭제 처리
- accessToken 유효기간이 짧아야 보안상 안전
- CORS/withCredentials:
- 쿠키 인증시 필수, 모든 fetch/axios에
withCredentials: true
적용
- 소셜 로그인 리다이렉트:
- 리다이렉트 직후 accessToken 별도 요청 fetch 로직 필수
✔️ 실전에서 겪은 문제/주의점
- 브라우저/서버 CORS 설정 불일치로 쿠키 전송/응답 헤더 미노출 오류
- accessToken을 저장하는 시점 놓치면 인증 API 401/403 빈발
- 소셜 로그인 리다이렉트시 2차 fetch 안하면 인증 불가
개발 과정중 에러/트러블슈팅
❌ 주요 이슈 및 해결 과정
✔️ 실전에서 겪은 문제/주의점
- 브라우저/서버 CORS 설정 불일치로 쿠키 전송/응답 헤더 미노출 오류
- accessToken을 저장하는 시점 놓치면 인증 API 401/403 빈발
- 소셜 로그인 리다이렉트시 2차 fetch 안하면 인증 불가
- JWT 일반 로그인과 OAuth2 소셜 로그인 통합 과정에서 DTO/Entity/인증객체 설계 충돌
- CustomOAuth2User와 CustomUserDetails를 하나로 통합하는 과정에서 각종 타입/인증 오류
- 프론트 개발시(웹서핑+AI 참고) 상태관리·라우팅·네트워크 로직 등에서 다양한 JS 오류
- 하이퍼링크 기반 소셜로그인에서 JWT를 어떻게 안전하게 전달할지 고민
개발 과정중 에러/트러블슈팅
❌ 주요 이슈 및 해결 과정
1. JWT 일반 로그인/소셜로그인 유저 구조 통합 실패
- 증상:
- 처음엔 UserDetails(일반)와 OAuth2User(소셜)를 따로 구현
- 인증 로직 중복, 공통 속성/로직 관리가 불편
- 코드 유지보수·확장성 저하
- 해결:
- 커스텀 UserPrincipal로 공통화
- Entity, DTO, 인증객체 간 변환 메서드 분리
- 인증 흐름(일반/소셜)이 하나의 서비스/컨트롤러로 통일됨
2. CustomOAuth2User와 CustomUserDetails 통합 중 에러
- 증상:
- 두 객체 통합하려다 Security 내부 인터페이스 충돌(GrantedAuthority, getAttributes 등)
- 인증/인가 로직이 꼬여서 예외 빈발
- 해결:
- 다중구현(implements) + 불필요한 필드 최소화
- 역할 분리(Principal, DTO, Entity)
- 인증객체 관련 메서드/필드 유틸화
3. 프론트 개발 중 인증상태/토큰 관리 혼선
- 증상:
- 상태관리/라우팅 실패, 토큰 누락, 쿠키 전송 오류 등 빈번
- 소셜 로그인/리다이렉트/인증 분기 불안정
- 해결:
- AuthContext 도입, 상태/토큰/유저정보 분리
- axios/fetch 네트워크 옵션 일원화, withCredentials 강제
- ChatGPT/구글링/공식문서 적극 참고
4. 소셜 로그인(JWT) 전달 방식 시행착오
- 증상:
- OAuth2 로그인 성공 후 accessToken을 JS로 받지 못해 인증 실패
- 처음엔 헤더로 전달하면 JS에서 접근 가능한 줄 알았음
- 해결:
- 실제 리다이렉트(302) 응답의 헤더는 JS에서 읽을 수 없음
- refreshToken만 httpOnly 쿠키로 발급 →
/oauth2/redirect
진입 후 즉시 /api/token
POST
- 응답 헤더에서 accessToken 받아 localStorage 저장
프로젝트 정리 및 느낀점
이번 프로젝트를 진행하면서 JWT 기반 인증 시스템을 직접 설계하고 구현해본 경험이 정말 값졌다고 느꼈습니다.
처음에는 단순히 “로그인만 되면 되지!”라고 생각했지만, HTTP 환경이 stateless하다는 점을 이해하고, 왜 JWT가 보안상 적합한지, 그리고 단일 토큰 대신 이중 토큰(Access + Refresh) 구조가 왜 필요한지 직접 경험을 통해 체감하게 되었습니다.
특히 소셜 로그인(OAuth2)과 기존 JWT 로그인 방식을 하나로 통합하는 과정에서
- DTO/Entity 구조를 어떻게 나눌지
- CustomOAuth2User와 CustomUserDetails를 통합하는 방법
- 프론트엔드에서 토큰/인증 상태를 안정적으로 관리하는 패턴
등에서 수많은 시행착오가 있었지만, 그만큼 많이 배웠습니다.
아직은 회원가입과 로그인 구현, 인증 흐름 구축까지만 완료했기 때문에
실제 서비스에서 발생할 다양한 예외상황(에러 처리,블랙리스트 등)은 충분히 테스트해보지 못해 아쉬움이 남습니다.
하지만 이 프로젝트를 기반으로, 앞으로 더 복잡한 사용자 시나리오, 보안 취약점 대응, 그리고 에러/트러블슈팅 케이스까지 직접 경험해보고, 그 내용을 계속해서 업데이트할 계획입니다.
이번 경험을 통해
- 인증/보안은 설계만큼이나 테스트, 반복 실험, 작은 에러까지 꼼꼼하게 체크하는 과정이 정말 중요하다는 걸 다시 한 번 느꼈습니다.
- 앞으로도, “stateless 환경에서 인증을 어떻게 안전하게 관리할 것인가?”라는 질문을 계속 고민하며, 더 다양한 인증 시나리오와 보안 솔루션을 직접 적용해보고 싶습니다.
🔗 Links
🚀 코드 전체 보러가기(GitHub)
📷 테스트 영상/스크린샷(벨로그)