[Spring] JWT 인증 구현기

jinsung·2026년 6월 6일

BootCamp

목록 보기
12/13
post-thumbnail

개요

마지막 프로젝트 때 로그인 인증 방식과 사용자 인증을 어떤식으로 굉장히 많은 고민을 했다. 세션과 JWT 중에는 뭘 쓸지, JWT를 쓴다면 토큰 관리는 어떻게 할지, 프론트에게 토큰을 어떻게 전달할 지, 많은 고민들을 공유해보려고 한다.

물론 3차 프로젝트때도 JWT를 사용해 토큰을 관리해보았지만 그 당시에는 리프레시토큰을 쿠키에 왜 넣어서 보관하는지도 리프레시토큰을 어떻게 쓰는지도 액세스 토큰을 어디에 보관해야되는지의 고민도 자세히 해보지 않고 사용해서 이번엔 정말 근거있게 선택해보자 생각했다.


1. 왜 세션 대신 JWT를 선택했나?

이번 프로젝트는 프론트는 React로 동작하고, 백엔드는 Spring Boot API 서버로 동작한다.
그리고 배포 환경에서도 프론트 도메인과 백엔드 도메인이 분리된다.

이런 구조에서는 API 요청마다 인증 정보를 명확하게 전달하는 JWT 방식이 잘 맞는다고 판단했고 크게 세 가지 이유가 있다.

첫 번째는 서버 확장성이다.

세션 방식은 서버가 로그인 상태를 저장해야 한다.
반면 JWT는 서버가 토큰 검증만 하면 되기 때문에 서버가 여러 대로 늘어나도 세션 공유 문제가 상대적으로 적다.

두 번째는 프론트와 백엔드 분리 구조에 잘 맞기 때문이다.

React 프론트에서 API 요청을 보낼 때 Authorization: Bearer accessToken 형태로 인증 정보를 명시적으로 보낼 수 있다.

마지막은 데이터베이스 부하가 적다는 점이다.

세션 방식은 서버가 로그인 상태를 세션 저장소에서 관리해야 한다. 서버가 여러 대로 늘어나면 세션 저장소를 공유해야 하므로 Redis 같은 외부 저장소가 필요할 수 있다. 반면 JWT는 서버가 토큰의 서명과 만료시간을 검증하면 되기 때문에 세션 저장소 의존도가 낮다.

JWT도 단점이 있다.

JWT는 한 번 발급하면 기본적으로 서버가 상태를 저장하지 않기 때문에, 토큰 만료 전까지는 강제 폐기가 어렵다.
그래서 accessToken의 만료 시간을 짧게 가져가고, refreshToken을 별도로 관리하는 방식을 선택했다.


2. JWT 토큰을 어떻게 관리할지?

토큰을 어디에 저장하고 어떻게 만료/재발급할지 잘못 설계하면 보안 위험이 커질 수 있다.

localStorage는 JavaScript로 접근 가능하기 때문에 XSS 공격이 발생해서 악성 스크립트가 실행되면 localStorage에 있는 토큰을 쉽게 읽을 수 있다.

물론 XSS는 애초에 막아야 하는 문제다.
하지만 토큰 저장 방식을 설계할 때도 XSS 발생 시 피해를 줄일 수 있는 방향을 선택하는 것이 좋다고 판단했다.

그래서 백에서 토큰을 내려줬을 때 프론트 메모리에 넣는 방법을 선택했다.
프론트는 React를 사용하고있어 useState 메모리를 선택했다.

백엔드는 로그인 성공 시 refreshToken을 HttpOnly Cookie로 저장한다. 이후 프론트는 refreshToken api를 호출하고, 이때 브라우저가 refreshToken 쿠키를 자동으로 함께 보낸다. 백엔드는 refreshToken을 검증한 뒤 accessToken을 응답 body로 내려주고, 프론트는 이 accessToken을 React useState에 저장한다.


3. AccessToken과 RefreshToken을 나눈 이유

처음에는 accessToken 하나만 사용해도 되지 않을까 생각했다.

하지만 보안 상 accessToken이 탈취되면 만료될 때까지 공격자가 계속 사용할 수 있기 때문이다.

그래서 토큰을 두 종류로 나눴다.

1. Access Token

→ 실제 API 인증에 사용
→ 만료 시간을 짧게 설정

2. Refresh Token

→ Access Token 재발급에 사용
→ 만료 시간을 길게 설정

이렇게 하면 accessToken의 만료 시간을 짧게 가져가도 사용자는 계속 로그인 상태를 유지할 수 있며 보안성과 UX까지 챙길 수 있다고 생각했다.

4. 프론트에서의 통신 방법

원래 프로젝트에서는 백엔드와 통신할 때 공통 axios 인스턴스를 사용했다.

이 방식은 로그인 없이 접근 가능한 API에서는 문제가 없었다.
예를 들어 음식점 지도 조회, 음식점 미리보기, 음식 카테고리 조회처럼 누구나 접근 가능한 public API는 accessToken이 필요하지 않다.

하지만 로그인한 사용자만 사용할 수 있는 API에서는 문제가 생겼다.

예를 들어 방문 인증, 찜하기, 내 정보 조회, 채팅방 생성 같은 API는 백엔드가 “이 요청을 보낸 사용자가 누구인지” 알아야 돼서 요청에 accessToken을 함께 보내야 한다.

하지만 문제가 있었다!!!!!

첫 번째, 인증이 필요한 모든 API마다 Authorization 헤더를 직접 붙여야 한다.

두 번째, accessToken이 만료되었을 때의 처리를 모든 API마다 반복해서 작성해야 한다.

그래서 인증이 필요한 API 요청을 위한 공통 함수 authFetch를 만들었다.

authFetch의 역할은 이다.

1. 현재 React 메모리에 저장된 accessToken을 전달받는다.
2. accessToken을 Authorization 헤더에 붙여 API 요청을 보낸다.
3. 응답이 성공이면 그대로 반환한다.
4. 응답이 401 또는 403이면 accessToken 만료로 판단한다.
5. /api/auth/refresh를 호출한다.
6. refreshToken 쿠키를 통해 새 accessToken을 발급받는다.
7. 새 accessToken을 React 메모리에 저장한다.
8. 처음 실패했던 API를 새 accessToken으로 다시 요청한다.

실제로 방문 인증 API에서 다음 흐름을 확인했다.

POST /api/visit/visit-verification → 403
POST /api/auth/refresh             → 200
POST /api/visit/visit-verification → 200

첫 번째 요청은 만료된 accessToken으로 인해 실패했다.
그 후 authFetch/api/auth/refresh를 호출했고, 새 accessToken을 받은 뒤 원래 방문 인증 API를 다시 호출했다.
결과적으로 사용자는 다시 로그인하지 않고 방문 인증을 완료할 수 있었다.

여기서 중요한 점은 /api/auth/refresh 자체를 authFetch로 호출하는 것은 아니라는 점이다.

구조는 다음과 같다.

authFetch
→ 일반 인증 API 호출
→ 401/403 감지
→ refreshAccessToken() 호출
→ /api/auth/refresh 호출
→ 새 accessToken 저장
→ 원래 API 재요청

authFetch는 인증이 필요한 일반 API 요청을 감싸는 함수이고, 내부에서 필요할 때만 refresh API를 호출한다.

그래서 우리 프로젝트에서는 API 요청 기준을 다음과 같이 정했다.

로그인 없이 접근 가능한 public API
→ 기존 axios 사용

로그인한 사용자만 접근 가능한 API
→ authFetch 사용

accessToken은 AuthContext의 useState에 저장된 값이다.

const [accessToken, setAccessToken] = useState<string | null>(null);

컴포넌트에서는 useAuth()를 통해 이 값을 꺼내고, API 함수에 전달한다.

const { accessToken, refreshAccessToken } = useAuth();

이렇게 분리해 public API는 단순하게 유지하고, 인증이 필요한 API에서는 accessToken 만료와 재발급 처리를 자동화했다.

profile
Data Engineer

0개의 댓글