Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
Tags
- 맥
- table status
- spring boot
- 알고리즘
- mycli
- 동적 프로그래밍 방법
- zsh
- Pager
- 오블완
- 이진탐색
- 오일러 경로
- VI
- oh-my-zsh
- 데이크스트라
- 인프런워밍업클럽
- cs
- 인프런
- mysql 표
- 욕심쟁이 방법
- 티스토리챌린지
- 분할정복 방법
- MySQL
- mysql 표 출력
- 스터디2기
- CS스터디
- 터미널
- 네트워킹데이
- 순차탐색
- zsh theme
- Less
Archives
- Today
- Total
Develop
[Spring Boot] 게시판 - 15. JWT 인증 구현하기 (Token 기반으로 전환) 본문
안녕하세요 ~ .ᐟ
어제는 Spring Security 의 Basic 인증을 구현했습니다.
Basic 인증은 매 요청마다 사용자명과 비밀번호를 Base64로 인코딩해서 보내야 하고, 서버에서 세션을 관리해야 하는 단점이 있었습니다.
그래서 오늘은 JWT를 이용한 무상태(Stateless) 인증 방식으로 전환을 해보겠습니다.
1. JWT (JSON Web Token) 란?
- 정의
- JSON 형태의 정보를 안전하게 전송하기 위한 토큰
- 구성
- 헤더 (Header) : 토큰 타입과 알고리즘 정보
- 페이로드 (Payload) : 실제 데이터 (사용자 이메일 등)
- 서명 (Signature) : 토큰이 변조되지 않았음을 증명
- 장점
- Stateless : 서버에 세션 저장 불필요
- 확장성 좋음 (MSA 환경에 적합)
- 모바일 앱에서 사용 편리
- 토큰 자체에 사용자 정보 포함 가능
- 주의할 점
- JWT Base64 로 인코딩되어있어서 누구나 디코딩이 가능하고, 내용을 볼 수 있습니다.
- 그래서 JWT 에는 비밀번호나 민감한 개인정보를 넣으면 안됩니다.
- 하지만 서명이 있어서 내용이 변조되었는지 검증이 가능합니다.
- JWT Base64 로 인코딩되어있어서 누구나 디코딩이 가능하고, 내용을 볼 수 있습니다.
2. AccessToken 과 RefreshToken
- 왜 Token 을 2개나 사용할까?
- 보안과 사용성의 균형을 위해서 입니다.
- AccessToken만 사용하면 유효기간을 길게하면 탈취됐을때 해킹범이 사용할 수 있는기간이 늘어나 위험하고, 짧게 설정하면 사용자가 자주 로그인을 해야돼서 사용자 경험이 저하되고 유저이탈로 이어질 수 있습니다.
- 그래서 RefreshToken 을 추가해서 AccessToken을 짧게 가져가고, RefreshToken을 자동으로 갱신하게 해 사용자 경험을 좋게 만들 수 있습니다.
- AccessToken
- 짧은 유효기간
- 실제 API 요청에 사용
- 보통 1시간 정도로 짧게 설정
- 탈취되어도 피해 최소화
- RefreshToken
- 긴 유효기간
- AccessToken 재발급에만 사용
- 보통 7~30일로 설정
- 더 안전한 곳에 보관 (HttpOnly 쿠키 등)
Filter vs Interceptor 의 차이
- 실행시점
- 요청 -> Filter -> DispatcherServlet -> Interceptor -> Controller
- Filter (서블릿 레벨)
- Spring 과 무관하게 동작
- 요청/응답 객체 변경 가능
- 인코딩, 보안, 로깅등에 사용
- JWT 인증은 Spring Security와 통합해야 하므로 Filter 사용
- Interceptor (Spring MVC 레벨)
- Spring Bean 접근 가능
- 컨트롤러 실행 전/후 처리
- 인증/인가, 로깅, API 호출 추적 등에 사용
3. 설정
// build.grade
implementation 'io.jsonwebtoken:jjwt-api:0.12.3' // Inteface
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' // 실제 구현체
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' // Json 파싱용
application.properties
- 환경변수로 처리된 부분은 .env 파일에 추가해줍니다.
# jwt
jwt.secret= ${JWT_SECRET} // 최소 32자 이상 작성
jwt.access-token-validity= ${JWT_ACCESS_EXPIRATION} // 1시간 : 3600000
jwt.refresh-token-validity= ${JWT_REFRESH_EXPIRATION} // 7일 : 604800000
JwtProperties
- application.properties 의 설정값을 Java 객체로 바인딩 합니다.
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private Long accessTokenValidity;
private Long refreshTokenValidity;
}
4. JwtUtil
- 토큰 생성/ 검증 로직
더보기
@Slf4j
@Component
public class JwtUtil {
private final JwtProperties jwtProperties;
private final SecretKey signingKey;
public JwtUtil(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
this.signingKey = Keys.hmacShaKeyFor(
jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)
);
}
// Access Token 생성
public String generateAccessToken(String email) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenValidity());
return Jwts.builder()
.subject(email)
.issuedAt(now)
.expiration(expiryDate)
.signWith(signingKey)
.compact();
}
// Refresh Token 생성
public String generateRefreshToken(String email) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenValidity());
return Jwts.builder()
.subject(email)
.issuedAt(now)
.expiration(expiryDate)
.signWith(signingKey)
.compact();
}
// Token 에서 이메일 추출
public String getEmailFromToken(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
// Token 유효성 검증
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("만료된 토큰: {}", e.getMessage());
throw new CustomJwtException("토큰이 만료되었습니다");
} catch (MalformedJwtException e) {
log.warn("잘못된 형식의 토큰: {}", e.getMessage());
throw new CustomJwtException("유효하지 않은 토큰입니다");
} catch (SecurityException e) {
log.warn("서명 검증 실패: {}", e.getMessage());
throw new CustomJwtException("토큰 검증에 실패했습니다");
}
}
}
JwtAuthenticationFilter
- JWT 인증 필터 (모든 요청에서 토큰을 검사하는 필터)
- OncePerRequestFilter 상속
- 한 요청당 한번만 실행을 보장하기 위해 사용
더보기
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 1. 헤더에서 JWT 토큰 추출
String token = extractToken(request);
// 2. 토큰이 있고 유효한 경우
if (token != null && jwtUtil.validateToken(token)) {
// 3. 토큰에서 이메일 추출
String email = jwtUtil.getEmailFromToken(token);
// 4. UserDetails 조회
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// 5. 인증 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 6. SecurityContext 에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 7. 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
5. SecurityConfig 수정
- addFilterBefore() : JWT 필터를 기본 인증 필터 앞에 배치
- `/auth/**`: 로그인/회원가입/토큰갱신 경로는 인증 불필요
더보기
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 테스트용 : CSRF 비활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// JWT 쓰므로 세션 안씀
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 없이 접근 가능한 엔드포인트
.requestMatchers(
"/", "/error",
"v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/actuator/health",
"/users",
"/auth/signup",
"/auth/login",
"/auth/refresh"
).permitAll()
// 나머지는 인증 필요
.anyRequest().authenticated()
)
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
)
.httpBasic(basic -> basic.disable()); // Basic Auth 안씀
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
RefreshTokenRequest
- AccessToken 이 만료되면 RefreshToken 으로 새로운 AccessToken 을 발급받습니다.
더보기
@Getter
@NoArgsConstructor
public class RefreshTokenRequest {
@NotBlank(message = "Refresh Token은 필수입니다")
private String refreshToken;
}
AuthController
- refreshToken 발급 요청 Api 생성 (원래는 재사용됨)
더보기
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenResponse>> refresh(
@Valid @RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(
ApiResponse.success(authService.refreshToken(request.getRefreshToken()))
);
}
AuthService
- 로그인 시 토큰 발급하는 로직으로 수정
더보기
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
public void signup(SignupRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateResourceException(ErrorCode.EMAIL_ALREADY_EXISTS, "이미 존재하는 이메일입니다.");
}
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.build();
userRepository.save(user);
}
public TokenResponse login(LoginRequest request) {
// 1. AuthenticationManager 에게 인증 요청
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// 2. 인증 성공 시 토큰 생성
String email = authentication.getName();
String accessToken = jwtUtil.generateAccessToken(email);
String refreshToken = jwtUtil.generateRefreshToken(email);
// 3. 응답 생성
return new TokenResponse(accessToken, refreshToken);
}
public TokenResponse refreshToken(String refreshToken) {
// 1. RefreshToken 검증
if (!jwtUtil.validateToken(refreshToken)) {
throw new CustomJwtException("유효하지 않은 Refresh Token 입니다.");
}
// 2. RefreshToken 에서 이메일 추출
String email = jwtUtil.getEmailFromToken(refreshToken);
// 3. 새로운 AccessToken 발급 (RefreshToken 은 재사용)
String newAccessToken = jwtUtil.generateAccessToken(email);
return new TokenResponse(newAccessToken, refreshToken);
}
}
6. 예외처리
JWT 관련 예외를 처리하기 위한 커스텀 예외를 만들어줍니다.
더보기
// CustomJwtException
public class CustomJwtException extends RuntimeException {
public CustomJwtException(String message) {
super(message);
}
}
// GlobalExceptionHandler
@ExceptionHandler(CustomJwtException.class)
public ResponseEntity<ApiResponse<Void>> handleCustomJwtException(CustomJwtException e) {
log.warn("JWT 예외 발생: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(e.getMessage()));
}
7. API 테스트
더보기
### 1. 로그인
POST http://localhost:8080/auth/login
Content-Type: application/json
{
"email": "test@example.com",
"password": "Test1234!@"
}
### 2. 인증이 필요한 API 호출
GET http://localhost:8080/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
### 3. 토큰 갱신 (AccessToken 만료 시)
POST http://localhost:8080/auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}
느낀 점
- 팀플당시에는 accessToken / refreshToken 개념만 인지하고 있었는데, 직접 구현해보니까 동작흐름을 알게되었다.
- 인증(Authentication)과 인가(Authorization)의 분리
- FilterChain 을 통한 요청 전처리로 인증된 사용자만 접근 가능하도록 설계
- 토큰 검증 -> 사용자 정보 추출 -> SecurityContext 등록의 과정
- 서비스 로직에서 @PreAuthorize or hasRole() 로 역할기반 인가 체크 (내일 쯤 적용할듯..)
- 토큰에 담는 정보
- JWT 는 Base64 인코딩이지 암호화가 아니라서 디코딩이되기때문에 절대 중요한 정보는 담으면 안된다.
- 비밀번호는 로그인 시 DB에 저장된 hash 값만 비교 검증하고, 이후에는 토큰으로만 인증하므로 JWT에 담을 필요가 없습니다.
- Stateless 아키텍처의 장점
- Basic 인증 -> JWT 로 전환
- 서버를 재시작해도 토큰만 있으면 인증이 유지된다.
- 여러 서버로 확장해도 세션 동기화가 불필요하다 (MSA 환경 준비)
- 모바일 앱에서도 동일한 방식으로 인증이 가능하다.
- Basic 인증 -> JWT 로 전환
RefreshToken DB 저장, 로그아웃, 강제 로그아웃 등..
내일 마저 하겠습니다 하하

'백엔드 > 게시판 만들기' 카테고리의 다른 글
| [Spring Boot] 게시판 - 17. JWT 인증에 권한 관리 더하기 - @PreAuthorize로 메서드 레벨 보안 구현 (0) | 2025.12.18 |
|---|---|
| [Spring Boot] 게시판 - 16. JWT 적용 + 스테이징 배포 트러블슈팅 기록 (1) | 2025.12.17 |
| [Spring Boot] 게시판 - 14. Spring Security 로그인 구현 (0) | 2025.12.14 |
| [Spring Boot] 게시판 - 13. 코드 리팩토링과 배포 파이프라인 개선 (0) | 2025.12.11 |
| [Spring Boot] 게시판 - 12. JPA N+1 문제 해결하기 (EntityGraph vs Fetch Join) (0) | 2025.12.09 |