프론트엔드-백엔드 연동 트러블슈팅 - Spring Security 500 에러 해결기

geoson·2025년 6월 5일

Spring & 백엔드

목록 보기
13/18

🎯 들어가며

Spring Boot로 소셜미디어 백엔드를 개발하고 HTML/JavaScript 프론트엔드와 연동하던 중 발생한 500 에러를 해결한 과정을 공유합니다.

🚨 문제 상황

환경

  • 백엔드: Spring Boot 3.5.0 + Spring Security + JWT + MariaDB
  • 프론트엔드: Vanilla JavaScript + HTML/CSS
  • 에러: 회원가입 API 호출 시 500 Internal Server Error

에러 로그

POST http://localhost:8080/api/auth/signup 500 (Internal Server Error)
NoResourceFoundException: No static resource api/auth

프론트엔드 요청 코드

// 회원가입 요청
async function signUp(userData) {
    try {
        const response = await fetch('http://localhost:8080/api/auth/signup', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(userData)
        });
        
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const result = await response.json();
        return result;
    } catch (error) {
        console.error('회원가입 실패:', error);
        throw error;
    }
}

🔍 원인 분석

1차 의심: 데이터베이스 연결 문제

처음에는 MariaDB 연결 문제로 생각했습니다.

MariaDB [picflow]> show tables;
+-------------------+
| Tables_in_picflow |
+-------------------+
| users             |
| posts             |
| comments          |
+-------------------+

하지만 DB는 정상이었습니다.

2차 의심: CORS 문제

CORS 설정을 추가했지만 여전히 해결되지 않았습니다.

진짜 원인 발견 💡

Spring Security가 API 엔드포인트를 정적 리소스로 잘못 인식하고 있었습니다!

에러 메시지의 핵심: NoResourceFoundException: No static resource api/auth
→ Spring이 /api/auth/signup을 정적 파일로 찾으려 했던 것!

🛠️ 해결 과정

문제 1: Spring Security 설정 오류

❌ 문제 코드

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()  // 모든 요청이 인증 필요!
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .build();
    }
}

✅ 해결 코드

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()      // 인증 API는 허용
                .requestMatchers("/api/posts").permitAll()        // 게시물 조회 허용
                .requestMatchers("/", "/index.html", "/css/**", "/js/**").permitAll() // 정적 리소스 허용
                .anyRequest().authenticated()                     // 나머지는 인증 필요
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

문제 2: JWT 필터가 모든 요청 가로채기

❌ 문제 상황

JWT 필터가 회원가입 요청까지 토큰 검증을 시도 → 순환 참조 발생

✅ 해결 코드

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) throws ServletException, IOException {
        
        String requestURI = request.getRequestURI();
        
        // 인증이 필요없는 경로는 스킵
        if (isPublicPath(requestURI)) {
            filterChain.doFilter(request, response);
            return;
        }
        
        // JWT 토큰 검증 로직
        String token = extractToken(request);
        if (token != null && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            Authentication authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private boolean isPublicPath(String requestURI) {
        return requestURI.startsWith("/api/auth/") ||
               requestURI.equals("/api/posts") ||
               requestURI.startsWith("/css/") ||
               requestURI.startsWith("/js/") ||
               requestURI.equals("/") ||
               requestURI.equals("/index.html");
    }
    
    private String extractToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

문제 3: CORS 설정 부족

✅ CORS 설정 추가

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

문제 4: 예외 처리 개선

✅ GlobalExceptionHandler 수정

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
            NoResourceFoundException e, HttpServletRequest request) {
        
        String requestURI = request.getRequestURI();
        log.warn("Resource not found: {}", requestURI);
        
        // API 경로와 정적 리소스 구분 처리
        if (!requestURI.startsWith("/api/")) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body(ApiResponse.error("정적 리소스를 찾을 수 없습니다."));
        }
        
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.error("API 엔드포인트를 찾을 수 없습니다: " + requestURI));
    }
    
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
        log.warn("Access denied: {}", e.getMessage());
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ApiResponse.error("접근 권한이 없습니다."));
    }
    
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ApiResponse<Void>> handleAuthentication(AuthenticationException e) {
        log.warn("Authentication failed: {}", e.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(ApiResponse.error("인증이 필요합니다."));
    }
}

✅ 해결 확인

curl 테스트 성공

$ curl -X POST http://localhost:8080/api/auth/signup \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "Test1234!",
    "username": "testuser"
  }'

{
  "success": true,
  "message": "회원가입이 완료되었습니다.",
  "data": null,
  "timestamp": "2025-06-03T11:30:00"
}

로그인 테스트 성공

$ curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "Test1234!"
  }'

{
  "success": true,
  "message": "로그인 성공",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "tokenType": "Bearer",
    "expiresIn": 3600
  },
  "timestamp": "2025-06-03T11:35:00"
}

프론트엔드 연동 성공

// 성공적인 회원가입 요청
document.getElementById('signup-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const formData = new FormData(e.target);
    const userData = {
        email: formData.get('email'),
        password: formData.get('password'),
        username: formData.get('username')
    };
    
    try {
        const result = await signUp(userData);
        alert('회원가입이 완료되었습니다!');
        window.location.href = '/login.html';
    } catch (error) {
        alert('회원가입 실패: ' + error.message);
    }
});

회원가입, 로그인, 게시물 작성 등 모든 기능이 정상 작동을 확인했습니다.

💡 핵심 교훈

1. Spring Security 설정 순서가 중요 🔑

// ✅ 올바른 순서
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**").permitAll()  // 먼저 허용할 경로
    .requestMatchers("/api/posts").permitAll()    // 공개 API
    .anyRequest().authenticated()                 // 그 다음 나머지
)

순서가 바뀌면 안 되는 이유: Spring Security는 위에서부터 순차적으로 매칭하므로 anyRequest().authenticated()가 먼저 오면 모든 요청이 인증을 요구합니다.

2. JWT 필터에서 인증 불필요 경로 제외 🚫

인증이 필요없는 엔드포인트는 JWT 필터에서 미리 걸러내야 합니다.

private boolean isPublicPath(String requestURI) {
    return requestURI.startsWith("/api/auth/") ||
           requestURI.equals("/api/posts") ||
           requestURI.startsWith("/css/") ||
           requestURI.startsWith("/js/");
}

3. CORS + Security 이중 설정 🔄

CORS와 Spring Security 설정이 모두 올바르게 구성되어야 합니다.

  • WebConfig에서 CORS 기본 설정
  • SecurityConfig에서 CSRF 비활성화 및 CORS 허용

4. 에러 로그의 진짜 의미 파악 🔍

NoResourceFoundException: No static resource api/auth
API를 정적 리소스로 잘못 인식하고 있다는 신호!

이런 에러가 나오면 Spring Security나 Web MVC 설정을 확인해야 합니다.

📋 트러블슈팅 체크리스트

Spring Security 관련

  • permitAll() 경로가 올바르게 설정되었는가?
  • requestMatchers 순서가 올바른가?
  • JWT 필터에서 공개 경로를 제외하고 있는가?
  • CSRF가 적절히 비활성화되어 있는가?

CORS 관련

  • WebConfig에서 CORS 설정이 되어 있는가?
  • allowedOriginPatterns("*")로 설정되어 있는가?
  • 필요한 HTTP 메서드들이 허용되어 있는가?

API 응답 관련

  • GlobalExceptionHandler가 적절히 설정되어 있는가?
  • API와 정적 리소스 경로를 구분하여 처리하는가?
  • 에러 응답 형식이 일관적인가?

🎯 결론

이번 트러블슈팅을 통해 배운 핵심 사항들:

  1. Spring Security 설정은 순서가 생명 - 허용할 경로를 먼저 설정
  2. JWT 필터와 Security 설정의 일관성 - 둘 다 동일한 경로를 처리해야 함
  3. 에러 메시지 세심한 분석 - 표면적 현상이 아닌 근본 원인 파악
  4. 단계별 테스트의 중요성 - curl → 브라우저 → 통합 테스트 순서

교훈: 복잡한 설정 문제일수록 기본기가 중요합니다. Spring Security와 CORS의 기본 개념을 정확히 이해하고 적용하는 것이 핵심입니다! 🚀


📚 참고 자료

0개의 댓글