📝 개요
Groupware 프로젝트는 Spring-Boot 백엔드 API 서버 구축과 React 프론트엔드 서버 구축 및 연동 학습에 중점을 둔 프로젝트이다. 이전 YBoard (게시판) 프로젝트에서 Spring Security 를 이용하여 보안 부분에 대한 프레임 워크를 사용했었는데, REST API 구조에서의 Spring Security 활용에 대해서 학습하기 위해서 보안 및 인증에 대한 라이브러리로 선택하게 되었다.
이전 프로젝트에서는 세션 인증 방식을 사용하면서 다중 서버에서의 중복 로그인 문제, Spring Security 의 세션 관리에 대해서 학습했지만, 이번 프로젝트는 프론트엔드와 백엔드가 분리되고 REST API 방식의 서버 구현이므로 새로운 인증 방식에 대한 부분을 찾아보았다.
그렇게 프론트와 백엔드 분리 구조에서 CORS, SameSite 등 브라우저에 대한 제약이 있고 CSRF 등 보안 이슈가 더 복잡해진다는 알게 되었다. 무상태 아키텍처와 보안에 대한 유연성을 챙기면서 React 의 SPA 구조의 장점을 살리기 위해서 JWT 인증 방식을 도입하게 되었다.
다음은 JWT 를 구현하면서 알게된 개념과 Backend 서버에 적용하는 과정에 대해서 설명한다.
📕 개념
1️⃣ Spring Security
Spring Security 는 Spring 기반 어플리케이션을 위한 표준 보안 (인증/인가) 프레임워크이다. 로그인, 권한 체크, CSRF, 세션/토큰관리, 패스워드 암호화, 필터링 등 인증(Authentication), 인가(Authorization) 기능을 통합 제공한다.
특징
- 인증 / 인가 분리 : 로그인, 토근 검증 (인증) / ROLE 체크, 권한 매핑 (인가)
- Filter 기반 동작 : Spring MVC 의
DispatcherServlet앞단에FIlterChain이 동작 - 자동화된 보안 정책 적용 : URL, HTTP Method 등 접근 제어 정책 (
requestMatchers, hasRole) - 유연한 커스터마이징 : 기본 제공 기능을 바탕으로
Custom Filter,Handler구현 가능 - 다양한 인증 방식 지원 : 폼 로그인, Basic 인증, OAuth2, 세션, 토큰(JWT), SMAL , LDAP 등
- 패스워드 암호화 및 보안 기능 :
BCrypt,SCrypt등 해시 제공 / CSRF, 세션 고정 공격 등 이슈 대응
Spring Security 는 스프링 진영 표준 인증/인가 프레임워크로, 기본 로그인~권한 체크는 물론, 커스텀 토큰(JWT), Oauth2 등 다양한 인증 방식을 Filter 구조 기반으로 유연하게 확장할 수 있다. 검증된 보안 기능을 직접 구현하지 않아도 신뢰성 있게 사용이 가능하고 빠른 구현이 가능하다는 장점이 있다.
2️⃣ REST API
REST (Representational State Transfer) 는 HTTP 를 기반으로 한 아키텍처 스타일이다. 자원을 URI 로 식별하고 HTTP 메서드 (GET, POST, PUT, DELETE) 로 자원을 조작한다. 무상태(Stateless) 원칙을 따르고 서버가 클라이언트의 이전 상태를 기억하지 않는다.
특징
- URI 로 자원 표현 (ex : /user/1)
- HTTP 메서드로 행위 표현 : GET(조회), POST(생성), PUT(수정), DELETE(삭제)
- Self-Descriptive (자체 설명 메세지) : 요청/응답만 보고 어떤 동작인지 알 수 있음
- Stateless (무상태) : 서버가 상태를 저장하지 않음 (모든 요청은 독립적)
- 계층 구조 : API Gateway, 프록시 등 다양한 계층 구조 지원
RESTful API 란 REST 원칙을 지켜 구현한 API 이다. REST API 와 혼용되지만 REST 원칙을 엄격하게 적용하면 RESTful 이라고 표현한다. Spirng + React 구조처럼 FE/BE 완전 분리 개발에 최적이다. 서버가 상태를 저장하지 않으니, 인증정보(JWT) 는 매 요청마다 포함한다. 쿠키/세션 인증은 REST 의 Stateless 원칙에 위배된다.
3️⃣ 쿠키(Cookie) & 세션(Session)
쿠키는 웹 브라우저(클라이언트)가 서버로부터 받은 작은 데이터 조각(Key-Value 형태)이다. 브라우저가 저장하고 이후 같은 도메인으로 요청할 때마다 자동으로 서버에 함께 전송한다. 이는 로그인 상태 유지, 사용자 설정 등에 활용된다. 쿠키는 서버가 아닌 클라이언트(브라우저)에 저장한다.
세션은 클라이언트가 서버 간의 상태를 서버가 관리하는 방식이다. 예를들어 사용자가 로그인을 하면, 서버가 고유한 세션ID 를 생성하고 이 세션ID 를 쿠키에 저장해서 클라이언트에 전달한다. 이후 모든 요청에 세션ID가 자동 포함되어 전송하고 서버는 세션ID 로 클라이언트 상태를 관리한다. 세션은 서버가 직접 상태(세션 데이터) 를 관리한다.
| 구분 | 쿠키 | 세션 |
|---|---|---|
| 저장 위치 | 클라이언트(브라우저) | 서버(메모리/DB/Redis 등) |
| 보안성 | 상대적으로 낮음(탈취/변조 위험) | 서버에 저장(보안 높음) |
| 용량 | 4KB 내외(제한) | 서버 메모리 한도(더 큼) |
| 만료 | 유효기간 지정(만료시 자동 삭제) | 세션 타임아웃/만료로 관리 |
| 인증 방식 | 직접 인증정보 저장 가능(비추천) | 세션ID로 인증정보 매핑 |
| 특징 | 클라-서버 간 정보 유지 용이, 보안 옵션 중요 | 서버에서만 관리, 서버확장시 주의 |
4️⃣ 세션 기반 인증 방식 (Session-Based Authentication)
[1] 로그인 요청
- 클라이언트가 ID/비밀번호로 로그인 요청
[2] 서버 인증 & 세션 생성
- 서버가 계정 정보 확인 후, 고유한 세션ID 생성
- 이 세션ID를 서버 저장소 (메모리, DB, Redis 등) 에 매핑 후 저장
[3] 세션 ID 클라이언트에 전달
- 서버가 Set-Cookie 헤더로 세션ID를 브라우저 쿠키에 저장
[4] 인증된 요청 처리
- 이후 클라이언트가 서버를 요청할 때마다 브라우저가 세션ID 쿠키를 자동을 같이 전송
- 서버는 세션ID로 어떤 클라이언트인지 식별, 요청 처리
장점
- 간단한 구현 :
Spring/Spring Security에서 자동 지원 - 서버 유저 상태/세션 관리 : 보안적 측면에서 상대적으로 안정적
- 브라우저 환경에서 사용 관리 : 쿠키에 자동 저장/전송
단점/제약
- 서버 확장(Scale-out) 시 복잡 : 여러 대의 서버(분산, 클러스터) 환경에서 세션 동기화 필요
→ 세션 불일치 해결을 위해 외부 세션 저장소 (ex : Redis),Sticky Session등 추가 구성 필요 - Stateless 원칙 위배 : 서버가 “상태” 를 기억하므로 RESTful/Serverless 구저에 부적합
- 모바일-SPA-외부 API 연동 불편 : 쿠키 기반이므로 CORS, CSRF 등 추가 이슈
- 세션 메모리 관리 필요 : 대량 트래픽/유저일 때 세션 관리 비용 증가
- 만료/로그아웃 처리 : 세션 타임아웃/강제 만료/로그아웃 등 볃로 처리 필요
5️⃣ JWT(JSON Web Token) 기반 인증 방식
토큰 기반 인증 방식은 클라이언트가 서버에 접속을 사면 서버에서 해당 클라이언트에게 인증되었다는 의미의 토큰을 부여한다. 이 토큰은 유일한 값이며, 토큰을 발급받은 클라이언트는 또 다시 서버에 요청을 보낼 때 요청 헤더에 토큰을 심어서 보낸다. 그러면 서버에서 클라이언트로부터 받은 토큰을 서버에서 제공한 토큰과 일치 여부를 체크하여 인증을 진행한다.
JWT 는 인정 정보(사용자, 권한, 만료 등)를 JSON 형태로 인코딩해서 하나의 토큰 문자열로 만든 것이다. 서버와 클라이언트가 토큰만 주고받으면서 Stateless 인증을 실현한다. JWT 는 JSON 데이터를 Base64 URL-safe Encode 를 통해 인코딩하여 직렬화 한것이다. 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 들어있다.
JWT 구조
<Header>.<Payload>.<Signature>
Header: 토큰 타입(JWT) & 서명 알고리즘Paylaod: 실제 담고 싶은 정보 (유저ID, 권한, 만료시간 등)Signature: Header 와 Payload 를 비밀키로 서명한 값 (위변조 방지)
JWT 인증 과정
[1] 로그인 요청
- 클라이언트가 ID/비밀번호로 로그인 요청
[2] 토큰 발급
- 서버에서 요청을 보낸 클라이언트에게 유일한 토큰 발급(엑세스, 리프래시)
- Header, Payload, Signature 정의 및 Base64 암호화 후 JWT 생성
- 발급된 토큰을 쿠키에 담아 클라이언트에게 전달
[3] 토큰 저장 (클라이언트 측 쿠키 OR 스토리지)
- 서버로부터 발급 받은 토큰을 클라이언트(브라우저)의 쿠키 혹은 스토리지에 저장
- 서버에 요청 시 해당 토큰을 HTTP 요청 헤더에 포함 (Authorization Header)
[4] 토큰 검증 (서버)
- 클라이언트로부터 온 토큰 검증
[*] 엑세스 토큰 만료 시 리프래시 토큰으로 새로운 엑세스 토큰 발급
장점
- Stateless (무상태) : 서버가 클라이언트 상태를 기억하지 않아도 됨 → REST API 최적
- 확장성/분산 환경 최적화 : 다수의 서버/클라우드 환경에서 세션 동기화 없이 인증 가능
- 다양한 클라이언트 지원 : 웹, 모바일, 외부 API 어디서나 HTTP 헤더만 있으면 가능
- 정보 내장 : 토큰 내부에 유저정보/권한/만료 등 원하는 데이터 저장 가능, DB 없이 인증/인가 가능
- 운영 효율성 : 서버 메모리/세션 관리 부담 감소
단점/주의점
- 토큰 강제 만료 어려움 : 세션과 달리 서버에서 “즉시 만료” 시키기 어려움 (블랙리스트 관리 필요)
- 탈취 시 보안 이슈 : 토큰이 노출되면 만료 전까지 악용 가능 (보관/전송 시 보안 주의 필요)
- 토큰 자체는 변조 불가지만, 정보 노출 가능 : Payload 는 Base64로 인코딩 (암호화 X) 민감 정보 주의
- RefrecshToken 관리 필요 : RefreshToken 은 DB/Redis 등 서버 저장 권장
JWT vs 세션 인증
| 구분 | 세션(Session) 인증 | JWT 토큰 인증 |
|---|---|---|
| 상태(State) | 서버가 직접 상태(세션)를 저장/관리 | 서버는 상태를 저장하지 않음(Stateless) |
| 저장 위치 | 세션 데이터: 서버 / 세션ID: 클라이언트 쿠키 | 토큰: 클라이언트(브라우저/앱/프론트) 보관 |
| 확장성 | 서버 확장, 분산 환경에 불리 | 서버 여러 대, 클라우드 환경에 최적 |
| 인증 흐름 | 세션ID로 서버에서 사용자 조회 | 토큰 자체로 모든 인증 정보 포함(자체 검증) |
| 만료/폐기 | 서버에서 강제 만료/로그아웃 가능 | 강제 만료 어려움(블랙리스트/DB 관리 필요) |
| 사용환경 | 웹 브라우저, 전통적인 웹앱에 최적화 | 웹/모바일/SPA/API 등 다양한 환경에 유리 |
| 보안 관리 | 세션 탈취, 세션 고정, CSRF 등 주의 | 토큰 탈취 시 만료까지 사용 위험(보관 중요) |
| REST 원칙 | Stateless 위배 | 완벽히 Stateless |
🎯 Spring Security 흐름

[일반 폼 로그인 흐름, 별도 커스터마이징 X]
[1] Http Request : 클라이언트 요청 (POST : /api/auth/login)
[2] AuthenticationFilter
[3] AuthenticationManager
[4~9] AuthenticationProvider, UserDetailsService 등
[10] SecurityContextHolder
[JWT 인증 방식 사용]
[1] Http Request : 클라이언트 요청 (/api/auth/login)
[2] JwtAuthenticationFilter
* AuthenticationFilter 대체 → 3번 과정 생략
* AuthService.login() : 사용자 인증 (4~9 번 과정 생략)
* JwtTokenProvider : UsernamePasswordAuthenticationToken 생성
[3] SecurityContextHolder
* JwtAuthenticationFilter 에서 지정
1️⃣ Spring Security 일반 폼 로그인 동작 흐름
[1] 클라이언트 로그인 요청
(POST /login + form-data: username, password)
↓
[2] UsernamePasswordAuthenticationFilter
├─ request에서 username/password 추출
├─ 인증 전 토큰 생성 (UsernamePasswordAuthenticationToken)
├─ setDetails (IP, SessionId 등 부가 정보 설정)
└─ AuthenticationManager.authenticate(token) 호출
↓
[3] ProviderManager (AuthenticationManager 구현체)
├─ 등록된 AuthenticationProvider 목록 순회
├─ provider.supports(token) 체크
└─ 적절한 Provider에게 인증 위임 (보통 DaoAuthenticationProvider)
↓
[4] DaoAuthenticationProvider
├─ retrieveUser() → UserDetailsService.loadUserByUsername()
├─ DB에서 유저 조회 → UserDetails 객체 리턴
├─ 비밀번호 비교 (PasswordEncoder.matches)
└─ 인증 성공 → 인증된 UsernamePasswordAuthenticationToken 리턴
↓
[5] UsernamePasswordAuthenticationFilter
└─ successfulAuthentication() 실행
├─ SecurityContextHolder.setAuthentication(...)
├─ Session에 인증 객체 저장
└─ 이후 요청부터 세션으로 인증 상태 유지
| 구간 | 핵심 |
|---|---|
| 필터 | UsernamePasswordAuthenticationFilter (Spring 기본 제공) |
| 인증 객체 생성 | 필터 내부에서 미인증 토큰 생성 → 인증 후 인증 토큰 반환 |
| 인증 로직 | AuthenticationManager → Provider → UserDetailsService |
| 세션 저장 | 인증 성공 시 SecurityContext + HttpSession에 인증 정보 저장 |
| 인증 유지 방식 | 이후 요청은 JSESSIONID 기반 세션 인증 사용 |
2️⃣ Spring Security + JWT 동작 흐름
[1] 클라이언트 로그인 요청
(POST /api/auth/login)
↓
[2] AuthController.login()
↓
[3] AuthService.login()
├─ userMapper.selectUserByUserId()
├─ passwordEncoder.matches()
├─ Authentication 직접 생성 (UsernamePasswordAuthenticationToken)
├─ JwtTokenProvider.generateToken(...) → Access, Refresh 발급
└─ RefreshToken 쿠키 저장
↓
[4] 응답으로 AccessToken + UserInfo 전달
↓
[5] 이후 요청 (ex: GET /api/notices)
↓
[6] Spring Security FilterChain 시작
↓
[7] JwtAuthenticationFilter (OncePerRequestFilter)
├─ AccessToken 추출 (Authorization 헤더)
├─ JwtTokenProvider.validationToken()
├─ userId 추출 (getUserId())
├─ Authentication 생성 (userId + role)
└─ SecurityContextHolder.setAuthentication(...)
↓
[8] 인가 처리 (ex: @PreAuthorize, hasRole)
↓
[9] Controller 도달 (인증된 사용자)
| 단계 | 설명 |
|---|---|
| [1]~[4] | 로그인 로직 → DB 조회 & 비밀번호 확인 → JWT 발급 |
| [5]~[9] | 인증된 요청 흐름 → 필터에서 토큰 확인 → 인증 객체 생성 → SecurityContext 저장 |
| 항목 | 세션 기반 | JWT 기반 방식 |
|---|---|---|
| 인증 필터 | Spring 기본 필터 사용 | 직접 로그인 처리 |
| 인증 유지 | 세션에 저장 | JWT에 담아서 클라이언트가 보관 |
| 인증 주체 조회 | UserDetailsService | 직접 Mapper 호출 |
| 인증 객체 생성 | 내부 인증 로직에서 자동 생성 | 서비스에서 직접 생성 |
| 보안 방식 | JSESSIONID 기반 | Bearer Token 기반 |
🚀 프로젝트 로그인(인증) 서비스 동작 흐름 / 적용
[1] 클라이언트 로그인 요청 (POST : /api/auth/login)
[2] AuthController → AuthService.login(LoginReqDTO.class, response) 호출
[3] 유저 정보 확인 + 비밀번호 검증 (PasswordEncoder.matches)
[4] Spring Security Authentication 생성 → userId, Password, grantedAuthority
[5] AccessToken, RefreshToken 생성 (JwtTokenProvider)
[6] RefreshToken HttpServletResponse 객체에 쿠키 추가
[7] AccessToken → LoginResDTO(accessToken, userInfo) 반환
1️⃣ DTO, VO
- UserVO : 사용자 VO (DB 1:1 매핑, 사용자 정보)
- UserInfoDTO : 로그인 후 사용자 정보 응답 DTO (사용자 기본 정보)
- LoginReqDTO : 로그인 요청 DTO (ID/PW)
- LoginResDTO : 로그인 응답 DTO (엑세스 토큰, 사용자 기본 정보)
- RefreshTokenResDTO : 토큰 갱신 응답 DTO (엑세스 토큰, 만료 기간)
// UserVO : VO 객체, DB 와 직접적인 통신 (서버)
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public class UserVO {
private String userId; // 사용자 ID
private String userPassword; // 사용자 비밀번호
private String userName; // 사용자 이름
private UserAuth userAuth; // 사용자 권한
// 회원등록 시 UserVO 로 변환
public static UserVO of(UserRegisterReqDTO dto) {
return UserVO.builder()
.userId(dto.getUserId())
.userPassword(dto.getUserPassword())
.userName(dto.getUserName())
.userAuth(UserAuth.valueOf(dto.getUserAuth()))
.build();
}
}
// UserInfoDTO : 로그인 완료 후 사용자 정보 전달 DTO (서버)
@Getter
@Builder
public class UserInfoDTO {
private String userId; // 사용자 ID
private String userName; // 사용자 이름
private UserAuth userAuth; // 사용자 권한
// UserVO 값 UserInfo 로 변경
public static UserInfoDTO of(UserVO userVO) {
return UserInfoDTO.builder()
.userId(userVO.getUserId())
.userName(userVO.getUserName())
.userAuth(userVO.getUserAuth())
.build();
}
}
// LoginReqDTO : 로그인 요청 DTO (클라이언트)
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public class LoginReqDTO {
@NotBlank(message = "아이디는 필수 입력값입니다.")
@Size(min = 4, max = 20, message = "아이디는 4~20자 이내로 입력해야 합니다.")
private String userId; // 사용자 ID
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
@Size(min = 8, max = 20, message = "비밀번호는 8자리 이상 입력해야 합니다.")
private String userPassword; // 사용자 비밀번호
}
// LoginResDTO : 로그인 응답 DTO (서버)
public record LoginResDTO(String accessToken, UserInfoDTO user) {
}
// RefreshTokenResDTO : 토큰갱신 응답 DTO (서버)
public record RefreshTokenResDTO(String accessToken, long accessExpiresIn) {
}
2️⃣ AuthController (Login, Refresh)
// AuthController
// 로그인 요청 API
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResDTO>> login(@Valid @RequestBody LoginReqDTO loginReqDTO, HttpServletResponse response) {
LoginResDTO loginResDTO = authService.login(loginReqDTO, response);
return ResponseEntity.ok(ApiResponse.success(loginResDTO, SuccessCode.LOGIN_SUCCESS));
}
// 토큰 갱신 API
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<RefreshTokenResDTO>> refresh(HttpServletRequest request) {
RefreshTokenResDTO refreshTokenResDTO = authService.refreshToken(request);
return ResponseEntity.ok(ApiResponse.success(refreshTokenResDTO, SuccessCode.TOKEN_REFRESH_SUCCESS));
}
3️⃣ AuthService (Login, RefreshToken)
// AuthService : 인증 서비스 -> 로그인, 토큰 갱신 요청과 응답 비즈니스 로직
/**
* 로그인
*
* @param loginReqDTO 로그인 요청 정보 (ID, 비밀번호)
* @param response HTTP 응답 객체 (쿠키 저장을 위해 사용)
* @return 로그인 응답 DTO (액세스 토큰 + 사용자 정보 포함)
*/
@Transactional(readOnly = true)
public LoginResDTO login(LoginReqDTO loginReqDTO, HttpServletResponse response) {
UserVO user = userMapper.selectUserByUserId(loginReqDTO.getUserId())
// 존재하지 않는 사용자 예외 처리
.orElseThrow(() -> new CustomException(ErrorCode.INVALID_CREDENTIALS));
// 비밀번호 검증
if (!passwordEncoder.matches(loginReqDTO.getUserPassword(), user.getUserPassword())) {
throw new CustomException(ErrorCode.INVALID_CREDENTIALS);
}
// Login Response 값으로 UserInfo 포함
UserInfoDTO userInfo = UserInfoDTO.of(user);
// Spring Security Authentication 객체 생성
Authentication authentication = new UsernamePasswordAuthenticationToken(
user.getUserId(),
user.getUserPassword(),
List.of(new SimpleGrantedAuthority(user.getUserAuth().getAuthority()))
);
// JWT 발급 (액세스 토큰 & 리프레시 토큰)
String accessToken = jwtTokenProvider.generateToken(authentication, TokenType.ACCESS);
String refreshToken = jwtTokenProvider.generateToken(authentication, TokenType.REFRESH);
int refreshTokenMaxAge = (int) jwtTokenProvider.getExpirationTime(TokenType.REFRESH);
// 리프레시 토큰을 HttpOnly 쿠키에 저장하여 응답
response.addCookie(CookieUtil.createHttpOnlyCookie(TokenType.REFRESH.name().toLowerCase(), refreshToken, refreshTokenMaxAge));
return new LoginResDTO(accessToken, userInfo);
}
/**
* RefreshToken 으로 Access 토큰 발급
*
* @param request HTTP 요청 객체 (refresh 요청)
* @return RefreshToken 응답 DTO (AccessToken, AccessToken 만료시간)
*/
public RefreshTokenResDTO refreshToken(HttpServletRequest request) {
String refreshToken = CookieUtil.getCookieValue(request, TokenType.REFRESH.name().toLowerCase());
if (refreshToken == null || refreshToken.isEmpty()) {
throw new CustomException(ErrorCode.MISSING_REFRESH_TOKEN);
}
// 토큰 검증
jwtTokenProvider.validationToken(refreshToken);
return new RefreshTokenResDTO(
jwtTokenProvider.refreshAccessToken(refreshToken), // String : accessToken
jwtTokenProvider.getExpirationTime(TokenType.ACCESS) // long : accessExpiresIn
);
}
4️⃣ Provider (JwtTokenProvider)
// JwtTokenProvider : JWT 발급 제공자
@Slf4j
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
private SecretKey signingKey;
// JWT 서명 키 생성 (SecretKey 객체)
@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.signingKey = Keys.hmacShaKeyFor(keyBytes);
}
/**
* JWT 토큰 생성 (Access / Refresh)
*
* @param authentication 인증 정보 (사용자 ID 및 권한 포함)
* @param tokenType 토큰 타입 (ACCESS / REFRESH)
* @return 생성된 JWT 토큰 문자열
*/
public String generateToken(Authentication authentication, TokenType tokenType) {
Date expiration = new Date(System.currentTimeMillis() + tokenType.getExpiration()); // TokenType : Access, Refresh
// 사용자의 권한(Role) 정보를 쉼표(,)로 구분하여 문자열로 변환
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
// JWT 생성 및 반환
return Jwts.builder()
.subject(authentication.getName()) // 사용자 ID
.claim("roles", authorities) // 사용자 역할(권한) 추가
.expiration(expiration) // 만료 시간 설정
.signWith(signingKey, Jwts.SIG.HS256) // 서명(Signature) 설정
.compact();
}
/**
* Refresh Token 검증 후 새로운 Access Token 발급
*
* @param refreshToken 리프레시 토큰
* @return 새로운 AccessToken
*/
public String refreshAccessToken(String refreshToken) {
Claims claims = parseClaims(refreshToken);
// Refresh Token 에서 사용자 정보 추출
List<GrantedAuthority> authorities = getAuthorities(claims);
// 새로운 Authentication 객체 생성 (Context 에 사용되지 않음 : false)
Authentication authentication = createAuthentication(claims.getSubject(), authorities, false);
// 새로운 Access Token 반환
return generateToken(authentication, TokenType.ACCESS);
}
/**
* JWT 토큰에서 Authentication 객체 생성
*
* @param token JWT 토큰
* @return Authentication 객체 (SecurityContext 에 저장 가능)
*/
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
// "roles" 클레임에서 권한 정보 추출
List<GrantedAuthority> authorities = getAuthorities(claims);
// 새로운 Authentication 객체 생성, (Context 에 사용됨 : true)
return createAuthentication(claims.getSubject(), authorities, true);
}
/**
* JWT 토큰 유효성 검증
*
* @param token 검증할 JWT 토큰
* @return 유효한 토큰이면 true, 그렇지 않으면 false
*/
public boolean validationToken(String token) {
try {
Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("JWT 만료 {}", e.getMessage());
throw new CustomException(ErrorCode.EXPIRED_TOKEN);
}
}
/**
* 만료 시간 반환
*
* @param tokenType ACCESS, REFRESH
* @return 토큰 만료시간 (초 단위)
*/
public long getExpirationTime(TokenType tokenType) {
return tokenType.getExpiration() / 1000; // 밀리초 -> 초 변환
}
/**
* JWT 토큰에서 Claims 파싱 (예외 처리 포함)
*
* @param token 파싱할 JWT 토큰
* @return Claims 객체 (토큰의 정보 포함)
* @throws CustomException 만료된 토큰 예외 처리
*/
private Claims parseClaims(String token) {
try {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
log.warn("JWT 만료 {}", e.getMessage());
throw new CustomException(ErrorCode.EXPIRED_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
log.warn("유효하지 않은 토큰 {}", e.getMessage());
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
}
/**
* JWT Claims 에서 사용자 권한 정보 추출
*
* @param claims JWT Claims
* @return GrantedAuthority 리스트
*/
private List<GrantedAuthority> getAuthorities(Claims claims) {
return Arrays.stream(claims.get("roles", String.class).split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
/**
* Authentication 객체 생성
*
* @param userId 사용자 ID
* @param authorities 권한 리스트
* @param forSecurityContext SecurityContext 에서 사용할 객체인지 여부
* @return 생성된 Authentication 객체
*/
private Authentication createAuthentication(String userId, List<GrantedAuthority> authorities, boolean forSecurityContext) {
if (forSecurityContext) {
// Spring Security User 객체 생성 (SecurityContext 에서 사용됨)
User principal = new User(userId, "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
} else {
// 단순 사용자 ID 기반 Authentication 객체 생성 (SecurityContext 미사용)
return new UsernamePasswordAuthenticationToken(userId, "", authorities);
}
}
}
5️⃣ Filter (JwtAuthenticationFilter)
// JwtAuthenticationFilter : JWT 인증 필터
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validationToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
/**
* Request Header -> Token 추출
*
* @param request HTTP 요청
* @return Token
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
6️⃣ Spring Security - Config
// Spring Security Filter Chain Config : 시큐리티 체인 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf((AbstractHttpConfigurer::disable))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 관련 API
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/logout").permitAll()
.requestMatchers("/api/auth/refresh").authenticated()
// 공지사항 API (조회는 로그인한 유저 가능, 추가, 수정, 삭제는 [ADMIN, MANAGER] 만 가능]
.requestMatchers(HttpMethod.GET, "/api/notices/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/notices").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/notices/{id}").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/notices/{id}").hasAnyRole("ADMIN", "MANAGER")
...
// 그 외 모든 요청은 로그인한 유저만 접근 가능
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
🕹 POSTMAN 설정 (요청 헤더 설정)
// 응답에서 JWT 토큰 추출 후 Postman 환경 변수에 저장 : 로그인 시 저장 (Post-Response)
var jsonData = pm.response.json();
pm.environment.set("accessToken", jsonData.data.accessToken);
// 환경 변수에서 토큰 가져오기 : 권한이 필요한 모든 요청에 추가 (Pre-Request)
var token = pm.environment.get("accessToken");
if (token) {
pm.request.headers.add({
key: "Authorization",
value: "Bearer " + token
});
}
🔗 REF
[Spring Security] Spring Security (JWT, Access Token, Refresh Token) - Spring Security 6.1 이후
들어가며Spring Security 6.1부터 기존에 사용하던 and()와 non-Lambda DSL Method가 Deprecated 되고, 필수적으로 Lambda DSL을 사용하도록 변경되었다. 변경된 내용으로 스프링 시큐리티 JWT 로그인을 구현해보려
jangjjolkit.tistory.com
🌐 Access Token & Refresh Token 원리
Access Token & Refresh Token 이번 포스팅에서는 기본 JWT 방식의 인증(보안) 강화 방식인 Access Token & Refresh Token 인증 방식에 대해 알아보겠다. 먼저 JWT(Json Web Token) 에 대해 잘 모르는 독자들은 다음 포스
inpa.tistory.com
🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)
Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해
inpa.tistory.com
[NODE] 📚 쿠키 & 세션 다루기
자바스크립트 쿠키 다루기 노드 쿠키 다루기에 앞서, 자바스크립트로 쿠키 다루는 법을 먼저 공부하고 오는 것을 추천한다. [JS] 📚 쿠키(Cookie) 🍪 다루기 선행 학습 [WEB] 🌐 쿠키 / 세션 정리 비
inpa.tistory.com
'Project > Backend' 카테고리의 다른 글
| [Groupware] Spring-Boot : 테스트 코드 (2) | 2025.06.26 |
|---|---|
| [Groupware] Spring Security + Exception (1) | 2025.06.18 |
| [Groupware] Spring-Boot : API 공통 응답 객체 (1) | 2025.05.30 |
| [Groupware] Spring-Boot : Swagger 연동 (0) | 2025.05.29 |