[트러블슈팅] Spring Boot JWT 인증 시스템 : 나만의 설계 기준을 잡아보자.
들어가며며
JWT 인증을 순수 Filter + ArgumentResolver 방식으로 구현한 코드를 보니 다음과 같은 수정 포인트들이 눈에 보였습니다. 아직 제가 컨트롤러 이전에 작동되는 부분에서 어떠한 구조로 설계를 하는게 좋을지 '나만의 설계 기준' 을 잡기 위해 여러 리펙토링 과정을 진행했습니다. 이 게시글은 제가 어느 부분에서 어떤 의문을 품고, 그 의문을 어떤 방식으로 해결해가는지 그 과정을 담았습니다.
인증/인가 시스템을 구현하기 전에 저만의 '설계도', 즉 구조를 설계해가는 과정을 나누고싶어 이 글을 작성했습니다.
기존 구조의 문제점
// 기존 아키텍처 - 총 6개 클래스 필요
JwtUtil + JwtFilter + FilterConfig + AuthUserArgumentResolver + WebConfig + PasswordEncoder
1. 과도한 클래스 분산
- JWT 로직이 여러 클래스에 흩어져 있음
- 각 클래스의 역할이 모호함
- 유지보수가 어려움
2. 일관성 부족
// 어떤 컨트롤러는 @Auth 사용
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(@Auth AuthUser authUser, ...) { }
// 어떤 컨트롤러는 직접 JWT 파싱
@DeleteMapping("/todos/{todoId}/managers/{managerId}")
public void deleteManager(@RequestHeader("Authorization") String bearerToken, ...) {
Claims claims = jwtUtil.extractClaims(bearerToken.substring(7));
// ...
}
3. 확장성 제한
- 복잡한 권한 체계 구현 어려움
- Spring Security 생태계와 분리됨
4. 보안 취약점
- 표준화되지 않은 보안 구현
- 토큰 무효화 기능 없음
- 에러 처리가 일관되지 않음
해결 방향 결정
Spring Security vs 순수 구현 고민
"처음부터 제대로 코드를 짜는 게 나중에 유리하다"는 원칙 하에 Spring Security + JWT 방식을 선택했습니다.
선택 이유:
- 실무에서 대부분 Spring Security 사용
- 표준화된 보안 프레임워크
- 확장성과 유지보수성 확보
- 복잡한 권한 관리 지원
리팩토링 과정
Step 1: 핵심 구조 설계
"JWT + Security 연동에 정말 필요한 것만 남기자"
// 새로운 구조 - 총 4개 클래스
UserPrincipal + JwtAuthenticationProvider + JwtAuthenticationFilter + SecurityConfig
Step 2: UserPrincipal 구현
고민: UserDetails 구현 vs 단순 POJO?
// 초기 고민: 단순하게 갈까?
public class UserPrincipal {
private Long id;
private String email;
private UserRole role;
}
// 최종 선택: UserDetails 구현으로 Spring Security 완전 통합
@Builder
public class UserPrincipal implements UserDetails {
private final Long id;
private final String email;
private final UserRole role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + role.name())
);
}
// ... 기타 UserDetails 메서드들
}
선택 이유:
- @PreAuthorize("hasRole('ADMIN')") 등 어노테이션 사용 가능
- Spring Security 권한 체계와 완전 통합
- 향후 확장성 확보
Step 3: JwtAuthenticationProvider 구현
핵심 인사이트: "JWT 관련 모든 로직을 한 곳에 집중하자"
@Component
public class JwtAuthenticationProvider {
// 기본 JWT 기능
public String createToken(Long userId, String email, UserRole userRole) { ... }
public boolean validateToken(String token) { ... }
public Claims extractClaims(String token) { ... }
// 브릿지 메서드들 (JWT ↔ Spring Security)
public UserPrincipal getUserPrincipal(String token) {
Claims claims = extractClaims(token);
return UserPrincipal.builder()
.id(Long.parseLong(claims.getSubject()))
.email(claims.get("email", String.class))
.role(UserRole.of(claims.get("userRole", String.class)))
.build();
}
public Authentication getAuthentication(String token) {
UserPrincipal userPrincipal = getUserPrincipal(token);
return new UsernamePasswordAuthenticationToken(
userPrincipal, null, userPrincipal.getAuthorities());
}
// 요청에서 토큰 추출까지 담당
public String extractTokenFromRequest(HttpServletRequest request) { ... }
}
개선 포인트:
- JWT 관련 모든 기능이 한 클래스에 집중
- Spring Security와의 브릿지 역할 명확화
- 토큰 추출 로직까지 Provider가 담당 (응집도 향상)
Step 4: JwtAuthenticationFilter 간소화
기존 복잡한 필터:
// AS-IS: 복잡한 로직
if (url.startsWith("/auth")) { ... } // 경로 체크
if (url.startsWith("/admin")) { ... } // 권한 체크
String jwt = jwtUtil.substringToken(bearerJwt); // 토큰 추출
Claims claims = jwtUtil.extractClaims(jwt); // 파싱
httpRequest.setAttribute("userId", ...); // 수동 설정
개선된 필터:
// TO-BE: 간단하고 명확
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
try {
// 1. 토큰 추출 (Provider 위임)
String token = jwtProvider.extractTokenFromRequest(request);
// 2. 인증 설정 (Provider 위임)
if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (JwtException e) {
// 예외 처리
request.setAttribute("jwtErrorCode", e.getErrorCode());
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
개선 효과:
- 필터는 Spring Security 연동에만 집중
- 모든 JWT 로직은 Provider에 위임
- 코드 가독성 대폭 향상
Step 5: SecurityConfig로 통합 관리
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
장점:
- 모든 보안 설정이 한 곳에 집중
- 경로별 권한 관리 명확화
- FilterConfig, WebConfig 등 불필요한 설정 클래스 제거
Step 6: 예외 처리 체계 분리
이 부분에 대해서는 다음 게시글에서 자세히 다뤄보겠습니다.
리팩토링 결과
Before vs After
구분기존 (AS-IS)개선 (TO-BE)
| 구분 | 기존 | 개선 후 |
| 클래스 수 | 6개 | 4개 |
| 코드 일관성 | ❌ 혼재됨 | ✅ 통일됨 |
| 확장성 | ❌ 제한적 | ✅ 우수함 |
| 권한 관리 | ❌ 수동 처리 | ✅ 어노테이션 기반 |
| 예외 처리 | ❌ 분산됨 | ✅ 체계적 |
주요 개선 효과
1. 코드 간소화
# 삭제된 클래스들
- JwtUtil → JwtAuthenticationProvider로 대체
- JwtFilter → JwtAuthenticationFilter로 대체
- FilterConfig → SecurityConfig로 대체
- AuthUserArgumentResolver → @AuthenticationPrincipal로 대체
- PasswordEncoder → Spring Security 표준으로 대체
2. 사용법 통일
// 모든 컨트롤러에서 동일한 패턴
@PostMapping("/todos")
public ResponseEntity<TodoSaveResponse> saveTodo(
@AuthenticationPrincipal UserPrincipal user,
@RequestBody TodoSaveRequest request) {
return ResponseEntity.ok(todoService.saveTodo(user, request));
}
3. 권한 관리 고도화
// 복잡한 권한 로직도 어노테이션으로 간단히
@PreAuthorize("hasRole('ADMIN') or @todoService.isOwner(#todoId, authentication.name)")
@PutMapping("/todos/{todoId}")
public void updateTodo(@PathVariable Long todoId, ...) { }
핵심 인사이트
1. "처음부터 제대로"의 진정한 의미
단순히 모든 기능을 처음에 구현하는 것이 아니라, 확장 가능한 구조로 설계하는 것이 중요합니다.
2. 표준 프레임워크의 힘
Spring Security라는 검증된 프레임워크를 활용함으로써:
- 보안 취약점 최소화
- 개발 생산성 향상
- 팀 협업 효율성 증대
3. 역할과 책임의 명확한 분리
- JwtAuthenticationProvider: JWT 전담
- JwtAuthenticationFilter: Spring Security 연동 전담
- SecurityConfig: 보안 설정 전담
- 예외 처리: 필터 vs 컨트롤러 레벨 분리
4. 점진적 개선의 중요성
한 번에 모든 것을 바꾸지 않고, 단계별로 리팩토링하면서 각 단계에서 검증하는 방식이 효과적이었습니다.
향후 개선 방향
- 토큰 처리 방식
- 다중 디바이스 로그인 지원
이번 리팩토링을 통해 확장 가능한 기반을 마련했으므로, 위 기능들을 추가하기가 훨씬 수월해질 것입니다.
결론: 때로는 "바퀴를 다시 발명"하지 말고, 검증된 프레임워크의 힘을 빌리는 것이 더 현명한 선택입니다.