이번 프로젝트를 시작하면서 가장 고민했던 것 중 하나는 사용자 인증 방식이다.
세션 기반 인증과 JWT 토큰 인증 방식 중 어떤 것을 선택해야 할지 고민이었다.
이번 글에서는 JWT 를 선택한 이유와 코드로 구현한 과정을 공유하고자 한다.
프로젝트 구조와 인증 방식
Run-ing 프로젝트의 경우 프론트엔드와 백엔드가 완전히 분리된 구조이며 API 를 통해 통신한다.
- 프론트엔드 : React
- 백엔드 : Spring Boot + Spring Security
- 인증 방식 : REST API 기반
인증 방식 비교 : 세션 vs JWT
먼저 세션과 JWT 에 대해서 간단하게 알아보자.
세션
- 동작 방식
- 사용자가 로그인하면 서버가 세션을 생성
- 세션 ID 를 쿠키에 담아 클라이언트에 전달
- 이후 요청마다 세션 ID 로 사용자 식별
- 장점
- 서버에 세션을 직접 관리하므로 즉시 무효화 가능
- 민감한 정보를 서버에만 보관
- 한계점
- 서버가 상태를 관리해야 함 (Stateful)
- 서버가 여러 대로 확장될 경우 세션 공유 문제 발생
- Redis 같은 외부 세션 저장소 필요
- 확장성과 운영 복잡도 증가
JWT
- 동작 방식
- 사용자가 로그인하면 서버가 JWT 발급
- 클라이언트가 토큰을 저장
- 요청 시 HTTP Header 에 토큰 포함
- 서버는 토큰의 서명 검증
- 장점
- Stateless : 서버가 상태를 저장하지 않음
- 수평 확장 용이 : 서버를 여러 대로 늘려도 인증 로직 변경 불필요
- 서버 부담 감소 : 클라이언트 측에서 인증 정보 유지
- REST API 친화적 : 무상태성이 RESTful 설계 원칙과 일치
- 세션 저장소 불필요
- 단점
- 토큰 탈취 시 만료 전까지 무효화 여려움
- Refresh Token 관리 필요
JWT 선택 이유
위에서 세션과 JWT 의 장단점을 알아봤다.
이 프로젝트에서 JWT 를 선택한 이유는 다음과 같다.
- 프론트엔드-백엔드 분리 구조에 적합
- JWT 의 무상태 특성 덕분에 REST API 의 설계 원칙과 자연스럽게 맞아 떨어진다.
- 확장 가능성
- 사용자가 늘어나면 서버를 여러 대로 확장해야 할 수 있다.
- JWT 를 사용하면 로드 밸런서만 추가하면 되고, 세션 공유를 위한 저장소가 필요없다.
- 운영 복잡도 감소
- 세션 저장소를 별도로 관리할 필요가 없어 초기 개발과 운영이 단순해진다.
물론 JWT 가 완벽하다고 할 수 없다.
몇 가지 단점이 있어 어떻게 대응할지는 다음과 같다.
- 토큰 탈취 문제
- Access Token : 만료 시간을 짧게 설정
- Refresh Token 도입 : 장기간 유효한 토큰으로 Access Token 재발급
- 토큰 크기 문제
- 불필요한 정보를 Payload 에 담지 않고, 최소한의 정보만 포함
Run-ing 프로젝트에서 JWT 인증을 선택한 것은 프로젝트의 구조적 특징과 향후 확장 가능성을 고려해 JWT 인증을 선택했다.
아래부터 어떻게 JWT 를 Run-ing 프로젝트에 구현했는지 설명하겠다.
1. 라이브러리 설정 및 application.yml 설정
JWT 를 사용하기 위해 라이브러리들을 추가한다.
- JWT 0.12.3 버전
dependencies{
...
//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
...
}
또한 application.yml 에 jwt 와 관련된 설정 정보를 저장해줘야 한다.
spring:
jwt:
secret: ${JWT_SECRET}
access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} # 기본값 1시간
refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800000} # 기본값 7일
secret key 또는 database관련 정보는 보안을 위해 .env 파일에 작성하고, .gitignore 을 이용하여
로컬 저장소에서만 관리하는 것이 좋다.
2. SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JWTUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L);
config.setExposedHeaders(Collections.singletonList("Authorization"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager,
CustomUserDetailsService customUserDetailsService) throws Exception {
// cors
http
.cors(cors -> cors
.configurationSource(corsConfigurationSource())
);
// csrf
http.csrf(auth->auth.disable());
// form 로그인 방식
http
.formLogin(auth->auth.disable());
// http basic 인증 방식
http
.httpBasic(auth->auth.disable());
// 경로별 인가 방식
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/users/signup").permitAll()
.requestMatchers(HttpMethod.POST, "/login").permitAll()
.anyRequest().authenticated()
);
// 필터 추가
http
.addFilterBefore(new JWTAuthenticationFilter(jwtUtil,customUserDetailsService),CustomUsernamePasswordAuthenticationFilter.class)
.addFilterAt(new CustomUsernamePasswordAuthenticationFilter(authenticationManager,jwtUtil),
UsernamePasswordAuthenticationFilter.class);
// 세션 설정
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}
Security Config 는 Spring Security 의 설정을 담당하고,
애플리케이션의 보안 정책, 필터 체인, 예외 처리 등을 구성한다.
어노테이션
- @Configuration
- Spring 설정 클래스임을 의미
- @EnableWebSecurity
- Spring Security 기능 활성화
메서드
- authenticationManager()
- passwordEncoder()
- corsConfigurationSource()
- securityFilterChain()
- CORS 설정을 기본값으로 설정
- CSRF 보호 비활성화
- CSRF 는 세션 + 쿠키 기반 인증에서만 의미 있음
- JWT 는 요청마다 토큰을 헤더에 실어 보내므로 CSRF 공격 대상이 아님
- form 로그인 방식 비활성화
- Spring Security 기본 로그인 방식
- 서버는 토큰 발급만 담당 + 프론트에서 로그인 처리
- http basic 인증 방식 비활성
- 매 요청마다 아이디/비밀번호 전송
- 보안에 취약하고 JWT 인증 방식과 충돌
- 경로별 인가 방식
- authorizeHttpRequests : URL 별 접근 권한을 정의
- permitAll : requestMatchers 에 있는 URL 은 로그인 없이 접근 가능
- hasRole : 특정 권한을 가진 사용자만 가능
- anyRequest().authenticated() : 매칭되지 않은 모든 요청은 인증 되어 있으면 접근 가능
- 필터 추가
- addFilterBefore : 특정 필터 전에 필터 삽입
- addFilterAt : 특정 필터 자리에 삽입
- 세션 설정
- 세션 정책 : STATELESS
- 서버가 세션을 생성하지도, 사용하지도 않음
- 인증 정보는 오직 요청 헤더의 토큰으로만 판단
3. 로그인 필터

UsernamePasswordAuthenticationFilter
FormLogin 의 한계
Spring Security 의 기본 로그인 방식은 formLogin() 이다.
formLogin() 은 로그인 성공 시 세션을 생성하고, HTML 폼 기반 처리를 제공한다.
즉, 세션 기반 인증을 전제로 하기 때문에 JWT 인증과는 맞지 않는다.
커스텀 로그인 필터
Form 로그인 방식에서는 클라이언트단이 username 과 password 를 전송한 뒤 Security 필터를 통과한다.
통과한 username 과 password 는 UsernamePasswordAuthenticationFilter 에서 회원 검증을 진행한다.
회원 검증은 DB 에서 조회한 데이터를 UserDetailsService 를 통해 받아 필터에서 호출한 AuthenticationManager 를 통해 진행한다.
앞서 SecurityConfig 에서 formLogin 방식을 비활성화 했기 때문에 로그인을 진행하기 위해서 필터를 커스텀 해서 등록해야 한다.
@Slf4j
@RequiredArgsConstructor
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
// 로그인 요청 시 사용자 인증 처리
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws
AuthenticationException {
// 로그인 시도
String username = obtainUsername(request);
log.info("로그인 시도 - username: {}", username);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password);
return authenticationManager.authenticate(authToken);
}
// 로그인 성공 시 JWT 토큰 발급
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
try {
// 사용자 정보 추출
CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal();
UUID userUuid = customUserDetails.getUesrUuid();
String email = customUserDetails.getEmail();
String name = customUserDetails.getUsername();
Role role = customUserDetails.getRole();
//jwt 토큰 생성
JWTUserDto user = new JWTUserDto(userUuid,email,name,role);
String token = jwtUtil.createAccessToken(user);
//응답 설정
response.addHeader("Authorization", "Bearer " + token);
log.info("로그인 성공 - name: {}, role: {}", name, role);
} catch (Exception e) {
log.error("JWT 토큰 생성 중 오류 발생", e);
}
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
log.warn("로그인 실패 - IP: {}, 이유: {}", request.getRemoteAddr(), failed.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
CustomUsernamePasswordAuthenticationFilter
- Spring Security 의 기본 로그인 필터를 상속
- 폼 로그인 대신 직접 인증 로직을 제어하기 위해 사용
- 커스텀 필터를 만들면 JWT 발급, JSON 응답 등 자유롭게 처리 가능
AuthenticationManager
- 인증을 실제로 수행하는 핵심 객체
- UserDetailsService 를 통해 DB 에서 사용자 조회
- 비밀번호 일치 여부 확인
- 인증 성공 시 - Authentication 객체 반환
- 인증 실패 시 - 예외 발생
attemptAuthentication
- FilterChain 에서 로그인 요청(POST /login)이 들어오면 실행됨
- 사용자 입력 추출
- JSON 로그인이라면 직접 파싱해야 함
- obtainUsername() / obtainPassword()
- HTTP 요청에서 아이디/비밀번호 값을 꺼내는 메서드
- UsernamePasswordAuthenticationFilter protected 메서드
protected String obtainUsername(HttpServletRequest request) { return request.getParameter(this.usernameParameter); }
- 인증 토큰 생성
- UsernamePasswordAuthenticationToken 생성
- 아직 인증되지 않은 상태의 토큰
- authorities = null 인 이유 : 인증 성공 후 주입 됨
- 인증 위임
- authenticationManager.authenticate(authToken)
- 아이디/비밀번호가 진짜인지 검증해서 맞으면 인증된 Authentication 을 돌려줘
- 인증 과정
- AuthenticationManager.authentication(authToken)
- 실제 구현체 : ProviderManager
- 여러 개의 AuthenticationProvider 관리
- ProviderManager.authenticate()
- 등록된 Provider 목록을 순회
- 처리 가능한 Provider 찾기
- DaoAuthenticationProvider 선택
- UsernamePasswordAuthenticationToken 처리 가능
- supports() 메서드로 확인
- DaoAuthenticationProvider.authenticate()
- retrieveUser() → UserDetailsService 호출
- UserDetailsService.loadUserByUsername(username)
- 실제 구현 : CustomUserDetailsService
- DB 에서 사용자 정보 조회
- User 객체 반환
- 비밀번호 검증
- PasswordEncoder.matches(rawPassword, encodedPassword)
- 성공 - 인증된 Authentication 생성, 실패 - 예외 발생
- AuthenticationManager.authentication(authToken)
- authenticationManager.authenticate(authToken)
successfulAuthentication - 인증 성공
- 인증 성공 시 JWT 토큰 생성 및 응답
- 과정
- 인증된 사용자 정보 꺼냄
- JWT 생성
- HTTP 응답 헤더에 토큰 담기
- 헤더에 담는 이유
- REST API 에서는 응답 본문에 데이터를 담음
- 하지만 인증 토큰은 HTTP 표준인 Authorization 헤더에 담음
- 클라이언트는 이후 요청 시 이 토큰을 다시 헤더에 담아 보냄
unsuccesfulAuthentication - 인증 실패
- 인증 실패 시 에러 응답
인증 흐름
전체 인증 과정을 단계별로 정리해보자.
1. 사용자 로그인 요청 (POST /login)
└─> CustomUsernamePasswordAuthenticationFilter.attemptAuthentication()
2. AuthenticationManager에게 인증 위임
└─> UsernamePasswordAuthenticationToken 생성
3. AuthenticationManager가 적절한 Provider 찾기
└─> DaoAuthenticationProvider 선택
4. UserDetailsService를 통해 DB에서 사용자 조회
└─> CustomUserDetailsService.loadUserByUsername()
5. 비밀번호 검증
└─> PasswordEncoder.matches()
6-A. 인증 성공
└─> successfulAuthentication() 호출
└─> JWT 토큰 생성 및 응답
6-B. 인증 실패
└─> unsuccessfulAuthentication() 호출
└─> 401 에러 응답
4. JWT 발급 및 검증
앞서 로그인 검증 필터를 만들었다.
하지만 실제로 JWT 를 생성하고 검증하는 핵심 로직은 JWTUtil 클래스에서 진행한다.
이번에는 JWTUtil 클래스를 구현하면서 내부 동작을 살펴보자.
JWT 란?
JWT (Json Web Token) 는 세 부분으로 구성된 문자열이다.
점(.) 으로 구분하고, Header, Payload, Signature 로 구성된다.
- Header : 토큰 타입과 암호화 알고리즘
- Payload : 실제 데이터
- Signature : 위변조 방지 서명
Header
{
"alg": "HS256",
"typ": "JWT"
}
- alg : 서명 알고리즘
- typ : 토큰 타입
Payload
{
"username": "user123",
"role": "ROLE_USER",
"iat": 1516239022,
"exp": 1516242622
}
- username, role : 커스텀 클레임 ( 넣은 정보)
- iat : 발급 시간
- exp : 만료 시간
Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
- 헤더와 페이로드를 합쳐서 비밀키로 서명한다.
- 서명으로 인해
- 토큰이 우리 서버에서 발급된 것인지 확인 가능
- 중간에 누군가 내용을 변조했는지 검증 가능
Base64 인코딩
Header 과 Payload 는 Base64 로 인코딩한다.
누구나 디코딩할 수 있으므로 비밀번호나 민감한 정보는 절대 넣으면 안된다.
비밀키 관리
JWT 서명에 사용할 비밀키는 코드에 직접 넣으면 안된다.
설정 파일에 저장하고 환경 변수로 관리하는 것이 좋다.
spring:
jwt:
secret: ${JWT_SECRET}
- 주의사항
- 최소 32자 이상
- Git 에 올리지 말고 환경 변수로 관리
JWTUtil
JWT 를 생성하고 검증하는 유틸 클래스를 만들어보자.
@Slf4j
@Component
public class JWTUtil {
private final SecretKey secretKey;
private final Long accessTokenExpiration; // Access Token 만료 시간
public JWTUtil(@Value("${spring.jwt.secret}") String secret,
@Value("${spring.jwt.access-token-expiration}") Long accessTokenExpiration
) {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); // 문자열 바이트 변환
this.secretKey = Keys.hmacShaKeyFor(keyBytes); // 키 길이 검증(HS256), 알고리즘(HmacSHA256) 자동 매핑
this.accessTokenExpiration = accessTokenExpiration;
}
// AccessToken 토큰 생성
public String createAccessToken(JWTUserDto user){
return createJWT(user,accessTokenExpiration);
}
// JWT 생성
private String createJWT(JWTUserDto user, Long expiration) {
Date now = new Date();
Date expirationDate = new Date(now.getTime() + expiration);
String token = Jwts.builder()
.subject(String.valueOf(user.userUuid()))
.claim("email", user.email())
.claim("name", user.name())
.claim("role", user.role().name())
.issuedAt(now)
.expiration(expirationDate)
.signWith(secretKey)
.compact();
log.info("JWT 토큰 생성 완료 - email: {} , name: {}, role: {}, expirationDate: {}",
user.email(), user.name(), user.role(), expirationDate);
return token;
}
// 토큰 유효성 검증
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("잘못된 JWT 서명입니다: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.warn("만료된 JWT 토큰입니다.: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰입니다.: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 잘못되었습니다.: {}", e.getMessage());
}
return false;
}
// Claims 추출
public Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
// 토큰에서 정보 추출
public UUID getUserUuid(Claims claims) {
return UUID.fromString(claims.getSubject());
}
public String getEmail(Claims claims) {
return claims.get("email", String.class);
}
public String getName(Claims claims) {
return claims.get("name", String.class);
}
public Role getRole(Claims claims) {
return Role.valueOf(claims.get("role", String.class));
}
public Date getExpired(Claims claims) {
return claims.getExpiration();
}
public Date getIssuedAt(Claims claims) {
return claims.getIssuedAt();
}
}
생성자 : Secret Key 초기화
- 비밀키 문자열을 바이트 배열로 변환
- 암호화 알고리즘은 문자열이 아닌 배열을 입력으로 받음
- UTF-8 인코딩 사용 (한글도 안전하게 처리)
- 안전한 키 생성 :
Keys.hmacShaKeyFor()- 키 길이 검증
- HS256 알고리즘은 최소 32바이트 필요
- 자동으로 검증해서 약한 키 사용 방지
- SecretKey 객체 생성
- 알고리즘 이름 자동 매핑(HmacSHA256)
- 키 길이 검증
JWT 생성 : createJWT()
- 발급 시간과 만료 시간 계산
- 토큰의 주체 설정 :
.subject()- Subject
- JWT 표준 클레임 중 하나(sub)
- 이 토큰이 누구에 대한 것인가? 를 나타냄
- UUID 를 사용하는 이유
- 절대 바뀌지 않는 고유값
- Subject
- 커스텀 정보 추가 :
.claims()- Claim
- JWT 의 Payload 에 저장되는 key-value 쌍
- Claim
- 서명 생성 :
.signWith()- 내부 동작
- 헤더와 페이로드를 합침
- HMAC-SHA256 해시 생성
- Base64 인코딩
- 필요한 이유
- 공격자가 Payload 를 변조하려고 시도한다면
- 변조된 Payload 로는 올바른 서명을 만들 수 없음
- 내부 동작
- 최종 토큰 생성 :
.compact()
JWT 검증 : validateToken()
토큰의 유효성을 검증하는 메서드이다.
- 예외 종류별 의미
SecurityException | MalformedJwtException- 서명이 일치하지 않음
- 토큰 형식이 잘못됨
ExpiredJwtException- 현재 시간이 토큰의 exp 를 넘어섬
UnsupportedJwtException- 암호화 알고리즘이 다름
IllegalArgumentException- 토큰이 null 이거나 빈 문자열
- Base64 디코딩 실패
- Claims 추출 :
getClaims()- 파서 빌더 생성 :
Jwts.parser()- JWT 를 파싱하기 위한 설정 시작
- 검증 키 설정 :
.verifyWith(secretKey)- 토큰을 . 으로 분리
- 서명 재생성
- 서명 비교
- 파서 생성 :
.build- 설정이 완료된 실제 파서를 생성
- 파싱 및 검증 :
.parseSignedClaims(token)- 토큰 분리 (Header,Payload,Signature)
- 서명 검증 ( 설정한 secretKey 로)
- Base64 디코딩
- JSON 파싱
- Claims 객체 생성
- 반환값 : Jws (서명된 클레임)
- Claims 추출 :
.getPayload()Claims 객체를 파라미터로 받아 각 정보를 추출한다.
Claims 를 파라미터로 받는 이유는 무엇일까?따라서 파라미터로 Claims 를 받는다. - 파싱에는 Base64 디코딩, JSON 파싱, 서명 검증 등이 포함되어있다.
토큰을 받아 매번 파싱하게 되면 이렇게 불필요한 연산이 반복된다. - 정보 추출 메서드들 : getXxx()
- 파서 빌더 생성 :
5. JWT 검증 필터
이제 클라이언트가 보낸 토큰을 검증하여 로그인된 사용자임을 확인하는 필터를 만들어야 한다.
JWT 검증 필터란?
로그인에 성공하면 클라이언트는 JWT 토큰을 받는다.
이후 모든 API 요청 시 이 토큰을 헤더에 담아 보낸다.
서버는 매 요청마다
- 토큰이 유효한지 검증
- 토큰에서 사용자 정보 추출
- 데이터베이스에서 사용자 존재 여부 확인
- Spring Security 가 인증된 사용자로 인식하도록 설정
이 과정을 자동화하는 것이 JWT 검증 필터이다.
JWT 검증 필터 구현
Spring Security 는 필터가 요청당 딱 한 번만 실행되도록 보장하는 OncePerRequestFilter 를 제공한다.
이를 상속 받아서 구현하면 된다.
@RequiredArgsConstructor
@Slf4j
public class JWTAuthenticationFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Authorization 헤더에서 JWT 토큰 추출
String authorization = request.getHeader("Authorization");
//Authorization 헤더 검증 : JWT 헤더가 없을 경우 다음 필터로 넘김
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.debug("JWT Token 을 request headres 에서 찾을 수 없음");
filterChain.doFilter(request, response);
return;
}
// Bearer 접두사 제거해 토큰 값 추출
String token = authorization.substring(7);
try{
// JWT 유효성 검증
if (!jwtUtil.validateToken(token)) {
log.warn("JWT token 유효성 검증 실패");
sendErrorResponse(response, "JWT token 유효성 실패");
return;
}
// JWT 유효성 검증 성공 후
Claims claims = jwtUtil.getClaims(token);
String email = jwtUtil.getEmail(claims);
log.debug("JWT token 인증 유저 : {}",email);
// 데이터베이스에서 사용자 정보 조회
UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);
// 사용자가 존재 하지 않는 경우
if (userDetails == null) {
log.warn("사용자가 존재하지 않음 : {}", email);
sendErrorResponse(response, "사용자가 존재하지 않음");
}
// security 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());
// 세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
}catch (Exception e) {
log.error("JWT Authentication 실패 : {}",e.getMessage());
sendErrorResponse(response, "Authentication 실패");
}
filterChain.doFilter(request, response);
}
private static void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"error\": \"" + message + "\"}");
}
}
doFilterInternal
- Authorization 헤더에서 토큰 추출 및 검증
- 클라이언트는 다음과 같은 형식으로 토큰을 보낸다.
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...- 에러를 내지 않고
doFilter()를 호출하는 이유- 모든 엔드포인트가 인증을 요구하는 건 아님
- Bearer 접두사 제거
- 7번 인덱스부터 하는 이유
- “Bearer “ - 7글자 (공백 포함)
substring(7): 7번 인덱스부터 끝까지 추출
- 7번 인덱스부터 하는 이유
- 유효성 검증
validateToken()을 호출해 JWT 유효성 검증을 함- 검증 실패 시 에러 응답을 보내는 이유
- 클라이언트가 토큰 문제임을 인지하고 재로그인 유도 가능
- 토큰에서 사용자 정보 추출
- 데이터베이스에서 사용자 정보 조회
- JWT 에서 이미 사용자 정보가 있는데 DB 를 조회하는 이유
- 사용자 계정 상태 확인 (JWT 만 검증하면 계정이 비활성화 되었는데도 접근이 가능함)
- 권한 변경 반영 (JWT 만 검증하면 오래된 권한으로 접근)
- 추가 정보 로드 (JWT 에는 최소한의 정보만 담고, 나머지는 DB 에서)
- JWT 에서 이미 사용자 정보가 있는데 DB 를 조회하는 이유
- 사용자 존재 여부 확인
- 토큰 발급 후 사용자가 계정 탈퇴
- 사용자 삭제
- SecurityContext 에 인증 정보 저장
- Spring Security 는 SecurityContext 에 인증 정보가 있으면 로그인된 사용자로 인식
- UsernamePasswordAuthenticationToken 파라미터
- principal(주체) : 사용자 정보
- credentials(자격 증명) : 비밀번호 → null(이미 검증 완료)
- authorities(권한) : 사용자의 권한 목록
- 다음 필터로 넘김
sendErrorResponse : JSON 에러 응답
REST API 는 모든 응답이 일관된 형식이어야 하기 때문에 JSON 으로 에러를 보낸다.
- 상태 코드 설정
- SC_UNAUTHORIZED = 401 = 인증 실패(토큰 없음, 만료, 유효하지 않음)
- Content-Type 설정
- 클라이언트에게 “ 이 응답은 JSON 이야” 라고 알려줌
- 문자 인코딩 설정
- 한글 에러 메시지가 깨지지 않도록
- JSON 응답 작성
6. UserDetails & UserDetailsService
앞서 JWT 토큰을 생성하고 검증하는 필터들을 만들었다.
하지만 Spring Security 는 어떻게 우리 데이터베이스의 User 엔티티를 이해하는 것일까?
Spring Security 와 우리 도메인 모델을 연결하는 다리 역할을 하는 법을 알아보자.
UserDetails와 UserDetailsService 란?
Spring Security 는 사용자 인증을 위해 두 가지 핵심 인터페이스를 제공한다.
- UserDetails
- Spring Security 가 이해할 수 있는 형태의 사용자 정보
- UserDetailsService
- 사용자 정보를 어디서 , 어떻게 가져올지 정의
User (우리 도메인) →CustomUserDetailsService : CustomUserDetails (변환) → Spring Security
CustomUserDetails
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new GrantedAuthority() {
@Override
public @Nullable String getAuthority() {
return user.getRole().toString();
}
});
return authorities;
}
@Override
public @Nullable String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail(); // email 을 username 으로 사용
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public @Nullable UUID getUesrUuid(){
return user.getUuid();
}
public @Nullable String getEmail(){
return user.getEmail();
}
public @Nullable String getName(){
return user.getName();
}
public @Nullable Role getRole(){
return user.getRole();
}
}
먼저 UserDetails 인터페이스를 구현하여 User 엔티티를 감싸는 래퍼 클래스를 만든다.
- 권한 반환 :
getAuthorities()- 사용자의 권한 목록을 Spring Security가 이해할 수 있는 형태로 반환
- 동작 순서
- 빈 권한 컬렉션 생성
- GrantedAuthority 익명 클래스 생성
- 사용자 식별자 :
getUsername()- 일반적으로 username 은 로그인 ID 를 의미한다.
- Run-ing 프로젝트는 이메일을 username 으로 사용한다.
- 비밀번호 :
getPassword() - 계정 상태 메서드 :
isXxx()isAccountNonExpired(): 계정 만료isAccountNonLocked(): 계정 잠금isCredentialsNonExpired(): 비밀번호 만료isEnabled(): 계정 활성
- 추가 getter 메서드
CustomUserDetailsService
이제 사용자 정보를 DB 에서 조회하여 CustomUserDetails 로 변환하는 서비스를 만들어보자.
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.debug("사용자 조회 시도 : {}",username);
// 이메일로 사용자 조회
User user = userRepository.findByEmail(username)
.orElseThrow(() -> {
log.warn("사용자를 찾을 수 없습니다 - email: {}", username);
return new UsernameNotFoundException("사용자를 찾을 수 없습니다. - email : " +username);
});
log.debug("사용자 로드 성공 - email: {}", username);
return new CustomUserDetails(user);
}
}
loadUserByUsername()- 파라미터
- username : 사용자 식별자 (프로젝트에서는 email 사용)
- 반환값
- UserDetails : Spring Security 가 이해할 수 있는 형태의 사용자 정보
- 예외
- UsernameNotFoundException : 사용자를 찾을 수 없을 때
- 파라미터
- 동작
- 데이터베이스에서 사용자 조회
- CustomUserDetails 로 변환 후 반환
이렇게 JWT 인증 방식을 프로젝트에 어떻게 적용했는지 알아보았다.
아직 완벽하게 구현되지는 않았고 몇 가지 보완할 점이 있다.
- Refresh 토큰
- 매번 DB 를 조회하면 느려지기 때문에, Redis 캐싱 적용
- 예외 및 로그 구체적으로 구현
기능이 추가적으로 구현이 되면 이 글에 추가하도록 하겠다.
참고자료
'런닝 코스 공유 서비스' 카테고리의 다른 글
| [런닝 코스 공유 서비스] - 13. 회원 정보 조회 및 수정 (0) | 2026.01.11 |
|---|---|
| [런닝 코스 공유 서비스] - 12. 회원 , 프로필 도메인 및 회원가입 API 구현 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 10. 공통 예외 처리 (0) | 2025.12.21 |
| [런닝 코스 공유 서비스] - 9. 공통 엔티티 - BaseEntity (0) | 2025.12.21 |
| [런닝 코스 공유 서비스] - 8. 프로젝트 초기 설정 (0) | 2025.12.21 |