Spring 7기 프로젝트/코드 개선 + 테스트 코드 프로젝트

[트러블슈팅] Spring Boot JWT 인증 시스템 : 나만의 설계 기준을 잡아보자.

JuNo_12 2025. 6. 6. 19:20

들어가며며

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. 점진적 개선의 중요성

한 번에 모든 것을 바꾸지 않고, 단계별로 리팩토링하면서 각 단계에서 검증하는 방식이 효과적이었습니다.

 

향후 개선 방향

  1. 토큰 처리 방식
  2. 다중 디바이스 로그인 지원

이번 리팩토링을 통해 확장 가능한 기반을 마련했으므로, 위 기능들을 추가하기가 훨씬 수월해질 것입니다.


결론: 때로는 "바퀴를 다시 발명"하지 말고, 검증된 프레임워크의 힘을 빌리는 것이 더 현명한 선택입니다.