Postman에서는 JSON이 잘 오는데 프런트엔드에서 호출하면 CORS 오류가 납니다. 서버가 죽은 것도 아닌데 브라우저만 실패하니, 일단 모든 요청을 허용하고 싶어집니다. 이때는 설정부터 넓히기보다 브라우저가 보낸 출처, OPTIONS 사전 요청, 실제 API 응답을 차례로 확인하는 편이 빠릅니다.
CORS는 브라우저가 다른 출처의 응답을 자바스크립트에 공개할지 판단하는 규칙입니다. API 도구나 curl의 성공만으로 브라우저에서도 읽을 수 있다고 결론 내릴 수 없습니다. 아래에서는 허용한 프런트엔드와 포트 하나가 다른 프런트엔드를 비교합니다.

주소창의 출처와 허용 목록이 같은지 봅니다
출처는 스킴·호스트·포트의 조합입니다. http://localhost:5173과 http://localhost:5174는 다른 출처이고, localhost와 127.0.0.1도 호스트가 다릅니다. 개발 서버의 포트가 바뀌었는데 백엔드에는 예전 주소가 남아 있는 경우부터 확인하세요.
브라우저 개발자 도구의 Network에서 실패한 API 요청을 열어 Origin을 봅니다. 허용 목록에는 페이지 경로나 끝의 슬래시를 붙이는 대신 실제 출처를 넣습니다. 개발 프록시를 쓰고 있다면 브라우저가 보는 요청 URL부터 확인하세요. 프록시 뒤의 내부 서버 주소와 브라우저 출처는 서로 다른 정보입니다.
OPTIONS가 실패했는지, 실제 요청이 실패했는지 나눕니다
모든 교차 출처 요청이 사전 요청을 만드는 것은 아닙니다. 이 예제는 X-Demo라는 사용자 정의 헤더를 넣어 OPTIONS가 발생하도록 했습니다. JSON POST나 Authorization 헤더가 있는 요청에서도 사전 요청이 필요한 경우를 만날 수 있습니다. 구체적인 조건은 MDN의 CORS 설명에서 확인할 수 있습니다.
- OPTIONS에서 막힘: 응답 상태와 허용 출처·메서드·헤더를 확인합니다. 401이나 로그인 페이지로의 리다이렉트라면 CORS보다 인증 필터가 먼저 처리하는지 살펴봅니다.
- OPTIONS는 통과했지만 GET·POST에서 막힘: 실제 응답에도 올바른
Access-Control-Allow-Origin이 있는지 확인합니다. 프록시가 응답을 바꾸거나 오류 응답에 헤더가 빠졌을 가능성도 확인 대상입니다. - 응답을 읽을 수 있고 상태가 401·403: CORS 외에 인증·권한·CSRF 조건을 확인합니다. 같은 403이라도 어느 요청에서 발생했는지에 따라 원인이 다릅니다.
Network에 사전 요청이 안 보이면 캐시된 허용 결과를 사용했는지도 살펴야 합니다. 서버 로그에서 OPTIONS만 찾거나 콘솔의 ‘CORS’ 한 단어만 보고 원인을 확정하지 마세요.
Spring Security 필터에 CORS 구성을 연결합니다
Spring Security 공식 문서는 사전 요청에 세션 쿠키가 없으므로 CORS 처리가 인증보다 먼저 이뤄져야 한다고 설명합니다. 아래 예제는 단일 필터 체인에 허용 목록을 직접 연결합니다. 기존 프로젝트에는 이미 있는 인증·인가 규칙을 유지하면서 CORS 부분을 반영해야 합니다.
재현 환경은 Spring Boot 4.1.1, Spring Security 7.1.1, Spring Framework 7.0.9, JDK 25.0.3입니다. Maven 부모는 spring-boot-starter-parent:4.1.1이며, spring-boot-starter-webmvc와 spring-boot-starter-security를 사용했습니다. 아래 클래스는 애플리케이션의 컴포넌트 스캔 범위에 둡니다.
package blog.cors;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class SecurityConfig {
@Bean
UrlBasedCorsConfigurationSource corsSource() {
CorsConfiguration cors = new CorsConfiguration();
cors.setAllowedOrigins(List.of("http://localhost:5173"));
cors.setAllowedMethods(List.of("GET"));
cors.setAllowedHeaders(List.of("X-Demo"));
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cors);
return source;
}
@Bean
SecurityFilterChain security(HttpSecurity http,
UrlBasedCorsConfigurationSource corsSource) throws Exception {
http.cors(cors -> cors.configurationSource(corsSource));
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/greeting").permitAll()
.anyRequest().authenticated());
http.httpBasic(Customizer.withDefaults());
return http.build();
}
}
/api/greeting은 인증 없이 읽는 설명용 GET API입니다. 나머지 요청은 인증을 요구하도록 남겨 두었습니다. CORS의 GET 허용은 브라우저 요청의 메서드 조건이고, permitAll()은 서버의 인가 규칙이므로 서로 대신하지 않습니다.
여러 SecurityFilterChain이 있다면 해당 URL을 실제로 처리하는 체인에 설정했는지 봐야 합니다. MVC의 @CrossOrigin과 별도 필터, 프록시 헤더를 동시에 추가하기 전에 기존 구성이 어디에 있는지 확인하세요.
같은 요청을 두 출처에서 실행해 봅니다
서버의 /api/greeting이 {"message":"hello"}를 반환하도록 두고, 5173과 5174 포트에서 각각 다음 코드를 실행했습니다. API는 18081 포트에서 실행했습니다. 헤더 값 1은 재현용 표시이며 인증 토큰이 아닙니다.
const response = await fetch("http://localhost:18081/api/greeting", {
headers: { "X-Demo": "1" },
credentials: "omit"
});
console.log(response.status, await response.json());
허용된 http://localhost:5173에서는 200과 JSON을 읽었습니다. 포트만 5174로 바꾸면 사전 요청에서 거절돼 자바스크립트에는 TypeError가 전달됐습니다. 반면 Origin을 보내지 않는 직접 HTTP GET은 200이었습니다. API 도구의 성공과 브라우저의 실패가 함께 나타날 수 있는 한 가지 재현입니다.
서버 응답을 따로 살피려면 다음처럼 사전 요청을 재현할 수 있습니다. curl은 헤더를 보여 주지만 브라우저의 CORS 차단을 대신 실행하지는 않습니다.
curl -i -X OPTIONS http://localhost:18081/api/greeting \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: X-Demo'
이 설정의 HTTP 테스트에서는 허용 사전 요청 200, 다른 출처 또는 미허용 헤더 403, 공개 GET 200을 확인했습니다. 인증이 필요한 /api/private는 허용 출처로 요청해도 401이었고, 그 응답에는 CORS 허용 헤더가 남았습니다. CORS 설정이 인증을 풀어 주지 않는다는 점도 함께 확인한 것입니다.
쿠키를 쓰는 요청은 조건을 더 확인합니다
위 예제는 쿠키 없이 응답을 읽는 흐름입니다. 세션 쿠키를 교차 출처로 보내는 서비스는 클라이언트의 credentials: "include"와 서버의 자격증명 허용을 함께 검토해야 합니다. 이때 허용 출처를 *로 두는 방식은 쓸 수 없습니다. Spring Framework의 자격증명 요청 안내처럼 신뢰하는 구체적 출처를 지정하세요. 쿠키의 SameSite·Secure 조건과 브라우저의 쿠키 정책도 별도로 적용됩니다.
mode: "no-cors"로 바꾸면 원하는 JSON을 자유롭게 읽게 되는 것이 아닙니다. Spring Security의 CORS 지원을 끄거나 CSRF를 무조건 비활성화하는 것도 이 문제의 일반적인 해결법이 아닙니다. 우선 Origin과 두 요청의 헤더를 맞춘 뒤, 남은 인증 실패를 따로 해결하세요.
응답이 브라우저에 정상적으로 도착한다면 다음으로는 Spring Boot 검증 오류를 JSON으로 응답하는 방법에서 입력 오류를 화면에 전달하는 구조를 살펴볼 수 있습니다.
확인 기준: 2026년 10월 9일. 로컬 단일 필터 체인과 두 개발 출처에서 재현했으며 운영 프록시·쿠키 로그인 조합을 모두 검증한 예제는 아닙니다.
'Spring Security' 카테고리의 다른 글
| Spring Framework Security 와 Session의 로그인 처리에 대한 차이 - 작성중 (0) | 2022.12.10 |
|---|
댓글