Spring Boot로 소셜미디어 백엔드를 개발하고 HTML/JavaScript 프론트엔드와 연동하던 중 발생한 500 에러를 해결한 과정을 공유합니다.
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;
}
}
처음에는 MariaDB 연결 문제로 생각했습니다.
MariaDB [picflow]> show tables;
+-------------------+
| Tables_in_picflow |
+-------------------+
| users |
| posts |
| comments |
+-------------------+
하지만 DB는 정상이었습니다.
CORS 설정을 추가했지만 여전히 해결되지 않았습니다.
Spring Security가 API 엔드포인트를 정적 리소스로 잘못 인식하고 있었습니다!
에러 메시지의 핵심: NoResourceFoundException: No static resource api/auth
→ Spring이 /api/auth/signup을 정적 파일로 찾으려 했던 것!
@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();
}
}
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;
}
}
@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);
}
}
@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 -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);
}
});
회원가입, 로그인, 게시물 작성 등 모든 기능이 정상 작동을 확인했습니다.
// ✅ 올바른 순서
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 먼저 허용할 경로
.requestMatchers("/api/posts").permitAll() // 공개 API
.anyRequest().authenticated() // 그 다음 나머지
)
순서가 바뀌면 안 되는 이유: Spring Security는 위에서부터 순차적으로 매칭하므로 anyRequest().authenticated()가 먼저 오면 모든 요청이 인증을 요구합니다.
인증이 필요없는 엔드포인트는 JWT 필터에서 미리 걸러내야 합니다.
private boolean isPublicPath(String requestURI) {
return requestURI.startsWith("/api/auth/") ||
requestURI.equals("/api/posts") ||
requestURI.startsWith("/css/") ||
requestURI.startsWith("/js/");
}
CORS와 Spring Security 설정이 모두 올바르게 구성되어야 합니다.
NoResourceFoundException: No static resource api/auth
→ API를 정적 리소스로 잘못 인식하고 있다는 신호!
이런 에러가 나오면 Spring Security나 Web MVC 설정을 확인해야 합니다.
permitAll() 경로가 올바르게 설정되었는가?requestMatchers 순서가 올바른가?WebConfig에서 CORS 설정이 되어 있는가?allowedOriginPatterns("*")로 설정되어 있는가?GlobalExceptionHandler가 적절히 설정되어 있는가?이번 트러블슈팅을 통해 배운 핵심 사항들:
교훈: 복잡한 설정 문제일수록 기본기가 중요합니다. Spring Security와 CORS의 기본 개념을 정확히 이해하고 적용하는 것이 핵심입니다! 🚀