현대 웹 애플리케이션 아키텍처는 프론트엔드와 백엔드를 분리하는 경향이 뚜렷합니다. 사용자와 상호작용하는 프론트엔드는 React, Vue, Angular와 같은 자바스크립트 프레임워크를 사용해 동적인 사용자 인터페이스를 구축하고, 데이터 처리와 비즈니스 로직을 담당하는 백엔드는 Spring Boot와 같은 강력한 프레임워크로 구현됩니다. 이러한 구조에서 프론트엔드 애플리케이션은 보통 자체 개발 서버(예: localhost:3000
)에서 실행되고, 백엔드 API 서버는 다른 포트(예: localhost:8080
)나 별도의 도메인(예: api.example.com
)에서 운영됩니다.
바로 이 지점에서 개발자들은 예상치 못한 난관에 부딪히게 됩니다. 프론트엔드에서 백엔드 API로 데이터를 요청했을 때, 브라우저 콘솔에 나타나는 붉은색의 CORS(Cross-Origin Resource Sharing) 오류 메시지가 그것입니다. 이 오류는 서버에 문제가 있어서가 아니라, 웹의 핵심 보안 모델 중 하나인 '동일 출처 정책(Same-Origin Policy, SOP)' 때문에 발생합니다. 이 글에서는 CORS가 무엇이며 왜 필요한지, 그리고 스프링 부트 환경에서 이를 효과적으로 다루는 다양한 방법을 심층적으로 분석합니다.
1. 모든 것의 시작점: 동일 출처 정책(Same-Origin Policy)
CORS를 이해하기 위해서는 먼저 웹 브라우저의 근간을 이루는 보안 정책인 동일 출처 정책(Same-Origin Policy, SOP)에 대한 이해가 선행되어야 합니다. SOP는 한 출처(Origin)에서 로드된 문서나 스크립트가 다른 출처의 리소스와 상호작용하는 것을 제한하는 매우 중요한 보안 메커니즘입니다.
만약 이 정책이 없다면, 악의적인 웹사이트(evil.com
)가 사용자로 하여금 정상적인 은행 사이트(mybank.com
)에 로그인하도록 유도한 뒤, evil.com
의 스크립트가 mybank.com
의 DOM에 접근하여 계좌 정보와 같은 민감한 데이터를 탈취하는 시나리오가 가능해집니다. SOP는 이러한 유형의 공격(Cross-Site Scripting, XSS 등)을 원천적으로 방지하는 역할을 합니다.
출처(Origin)란 무엇인가?
'출처'는 다음 세 가지 요소의 조합으로 결정됩니다.
- 프로토콜 (Protocol):
http://
,https://
등 - 호스트 (Host):
example.com
,sub.example.com
등 - 포트 (Port):
:80
,:443
,:8080
등 (명시되지 않은 경우 프로토콜의 기본 포트)
이 세 가지 요소가 모두 일치해야 '동일 출처'로 간주됩니다. 다음은 출처 비교의 예시입니다. http://example.com/app/index.html
를 기준으로 비교해 보겠습니다.
비교 URL | 결과 | 이유 |
---|---|---|
http://example.com/app/other.html |
성공 | 경로(Path)는 출처 판단에 영향을 주지 않음 |
https://example.com/app/index.html |
실패 | 프로토콜 불일치 (http vs https) |
http://www.example.com/app/index.html |
실패 | 호스트 불일치 (example.com vs www.example.com) |
http://example.com:8080/app/index.html |
실패 | 포트 불일치 (80 vs 8080) |
SOP는 매우 강력한 보안 장치이지만, 현대적인 웹 애플리케이션처럼 여러 출처의 리소스를 조합하여 사용하는 경우에는 걸림돌이 됩니다. API 서버, CDN, 인증 서버 등이 모두 다른 출처를 가질 수 있기 때문입니다. 이러한 제약을 안전하게 완화하기 위해 등장한 표준이 바로 CORS입니다.
2. CORS의 동작 메커니즘: 단순 요청과 프리플라이트 요청
CORS는 서버와 클라이언트(브라우저) 간의 특정 HTTP 헤더 교환을 통해 다른 출처의 리소스 요청을 허용할지 여부를 결정하는 메커니즘입니다. 모든 교차 출처 요청이 동일하게 처리되는 것은 아니며, 브라우저는 요청의 특성에 따라 두 가지 주요 방식으로 CORS를 처리합니다.
2.1. 단순 요청 (Simple Request)
특정 조건을 만족하는 요청은 '단순 요청'으로 분류됩니다. 이 경우, 브라우저는 서버에 예비 요청(Preflight) 없이 바로 본 요청을 보냅니다. 단순 요청의 조건은 다음과 같습니다.
- 메서드(Method)가 다음 중 하나여야 합니다:
GET
HEAD
POST
- 헤더(Header)는 브라우저가 자동으로 설정하는 헤더(예:
Connection
,User-Agent
)와 아래의 헤더들만 포함할 수 있습니다:Accept
Accept-Language
Content-Language
Content-Type
Content-Type
헤더는 다음 값들만 허용됩니다:application/x-www-form-urlencoded
multipart/form-data
text/plain
이 조건들을 보면, 과거 HTML 폼(Form)을 통해 데이터를 전송하던 방식과 유사하다는 것을 알 수 있습니다. 단순 요청의 처리 과정은 다음과 같습니다.
- 클라이언트 → 서버: 브라우저는 요청을 보낼 때 자동으로
Origin
헤더를 추가합니다. 이 헤더에는 요청을 시작한 페이지의 출처(예:http://localhost:3000
)가 포함됩니다. - 서버 → 클라이언트: 서버는 요청을 처리한 후, 응답에
Access-Control-Allow-Origin
헤더를 포함하여 회신합니다. 이 헤더에는 리소스 접근을 허용하는 출처(예:http://localhost:3000
또는 모든 출처를 허용하는*
)가 명시됩니다. - 브라우저의 판단: 브라우저는 수신한 응답의
Access-Control-Allow-Origin
헤더 값을 요청 시 보냈던Origin
헤더 값과 비교합니다. 값이 일치하거나*
이면 요청은 성공적으로 완료되고, 자바스크립트는 응답 데이터에 접근할 수 있습니다. 만약 헤더가 없거나 값이 일치하지 않으면 브라우저는 응답을 폐기하고 CORS 오류를 발생시킵니다.
2.2. 프리플라이트 요청 (Preflight Request)
단순 요청의 조건을 벗어나는 대부분의 현대적인 API 요청은 '프리플라이트 요청'을 통해 처리됩니다. 예를 들어, PUT
, DELETE
와 같은 메서드를 사용하거나, Content-Type
이 application/json
이거나, Authorization
같은 커스텀 헤더를 포함하는 요청이 여기에 해당합니다.
'프리플라이트(Preflight)'라는 이름처럼, 브라우저는 실제 요청을 보내기 전에 서버가 해당 요청을 수락할 의사가 있는지 확인하기 위해 OPTIONS
메서드를 사용한 예비 요청을 먼저 보냅니다. 이 과정은 비행기가 이륙(본 요청)하기 전에 관제탑(서버)에 허가를 구하는 것과 유사합니다. 이 과정은 개발자가 직접 코드를 작성하는 것이 아니라 브라우저에 의해 자동으로 수행됩니다.
프리플라이트 요청의 핸드셰이크 과정은 다음과 같습니다.
- 클라이언트 → 서버 (프리플라이트 요청): 브라우저가
OPTIONS
메서드로 서버에 요청을 보냅니다. 이 요청에는 다음 헤더들이 포함됩니다.Origin
: 요청 출처 (예:http://localhost:3000
)Access-Control-Request-Method
: 실제 요청에서 사용할 HTTP 메서드 (예:PUT
)Access-Control-Request-Headers
: 실제 요청에서 사용할 커스텀 헤더 (예:Content-Type
,Authorization
)
- 서버 → 클라이언트 (프리플라이트 응답): 서버는 이
OPTIONS
요청을 받고, 앞으로 올 실제 요청을 허용할 것인지에 대한 정책을 담아 응답합니다.Access-Control-Allow-Origin
: 요청을 허용할 출처 (예:http://localhost:3000
)Access-Control-Allow-Methods
: 허용할 HTTP 메서드 목록 (예:GET, POST, PUT, DELETE
)Access-Control-Allow-Headers
: 허용할 헤더 목록 (예:Content-Type, Authorization
)Access-Control-Max-Age
: 이 프리플라이트 응답을 브라우저가 캐시할 수 있는 시간(초). 이 시간 동안 동일한 유형의 요청에 대해서는 프리플라이트 요청을 생략하고 바로 본 요청을 보낼 수 있어 성능이 향상됩니다.
- 브라우저의 판단: 브라우저는 프리플라이트 응답 헤더를 보고 실제 요청(예:
PUT
)이 허용되는지 확인합니다. 모든 조건이 충족되면, 브라우저는 드디어 실제 요청을 서버로 보냅니다. 만약 서버가 프리플라이트 요청에 대해 허용하지 않는 정책으로 응답하거나 응답이 없으면, 실제 요청은 전송되지 않고 CORS 오류가 발생합니다. - 클라이언트 → 서버 (실제 요청): 프리플라이트 검증이 성공하면, 단순 요청과 마찬가지로
Origin
헤더를 포함한 실제 요청이 전송됩니다. - 서버 → 클라이언트 (실제 응답): 서버는 실제 요청을 처리하고, 응답에
Access-Control-Allow-Origin
헤더를 포함하여 회신합니다.
대부분의 CORS 관련 문제는 이 프리플라이트 요청 단계에서 발생합니다. 개발자는 브라우저 개발자 도구의 '네트워크(Network)' 탭에서 OPTIONS
요청이 실패했는지, 서버가 올바른 헤더로 응답했는지 반드시 확인해야 합니다.
3. 스프링 부트에서 CORS 설정하기: 세 가지 접근 방식
스프링 부트(Spring MVC 기반)는 CORS를 처리하기 위한 매우 편리하고 체계적인 방법을 제공합니다. 애플리케이션의 요구사항과 구조에 따라 전역적으로 설정하거나, 특정 컨트롤러나 메서드에만 선택적으로 적용할 수 있습니다.
3.1. 전역 설정: `WebMvcConfigurer` 활용
애플리케이션의 모든 엔드포인트에 일관된 CORS 정책을 적용하고 싶을 때 가장 이상적인 방법입니다. @Configuration
어노테이션이 붙은 설정 클래스에서 WebMvcConfigurer
인터페이스를 구현하고 addCorsMappings
메서드를 오버라이드하여 설정합니다.
이 방식은 CORS 설정을 한 곳에서 중앙 관리할 수 있어 유지보수가 용이하며, 보안 정책을 일관되게 가져갈 수 있다는 장점이 있습니다.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") // "/api/"로 시작하는 모든 경로에 대해
.allowedOrigins("http://localhost:3000", "https://my-frontend.com") // 허용할 프론트엔드 출처 명시
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") // 허용할 HTTP 메서드
.allowedHeaders("*") // 모든 헤더 허용
.allowCredentials(true) // 쿠키/인증 정보 포함 요청 허용
.maxAge(3600); // 프리플라이트 요청 캐시 시간(초)
}
}
위 예제 코드의 각 설정 항목을 자세히 살펴보겠습니다.
addMapping("/**")
: CORS 정책을 적용할 URL 패턴을 지정합니다./**
는 모든 경로를 의미하며,/api/**
와 같이 특정 경로에만 적용할 수도 있습니다.allowedOrigins(...)
: 가장 중요한 설정으로, 리소스 접근을 허용할 출처 목록을 지정합니다. 프로덕션 환경에서는"*"
(모든 출처 허용) 대신 신뢰할 수 있는 프론트엔드 도메인을 명시적으로 지정하는 것이 보안상 매우 중요합니다.allowedOriginPatterns(...)
:allowedOrigins
보다 더 유연한 패턴을 사용할 수 있습니다. 예를 들어,"https://*.my-domain.com"
와 같이 서브도메인을 포함하는 패턴을 지정할 수 있습니다.allowedMethods(...)
: 허용할 HTTP 메서드를 지정합니다."*"
을 사용할 수도 있지만, API에서 실제로 사용하는 메서드만 명시하는 것이 좋습니다. 프리플라이트 요청을 처리하기 위해"OPTIONS"
를 포함하는 경우가 많습니다.allowedHeaders(...)
: 허용할 요청 헤더를 지정합니다."*"
는 모든 헤더를 허용합니다.allowCredentials(true)
: 이 설정은 자격 증명(쿠키, HTTP 인증, 클라이언트 SSL 인증서)을 포함한 요청을 허용할지 여부를 결정합니다. 클라이언트에서axios.defaults.withCredentials = true;
와 같은 설정을 사용한다면, 서버에서도 반드시 이 값을true
로 설정해야 합니다. 주의할 점은, 이 값을true
로 설정할 경우allowedOrigins
에 와일드카드("*"
)를 사용할 수 없으며, 반드시 특정 출처를 명시해야 합니다. 이는 보안상의 이유로 브라우저가 강제하는 정책입니다.maxAge(...)
: 프리플라이트 요청에 대한 응답을 브라우저가 캐시할 시간을 초 단위로 설정합니다. 이 시간 동안은 동일한 URL, 메서드, 헤더 조합의 요청에 대해 프리플라이트 요청을 생략하므로 불필요한 네트워크 왕복을 줄여 성능을 개선할 수 있습니다.
3.2. 개별 설정: `@CrossOrigin` 어노테이션
모든 컨트롤러가 아닌 특정 컨트롤러나 특정 메서드에만 다른 CORS 정책을 적용해야 할 경우 @CrossOrigin
어노테이션을 사용하면 편리합니다. 이 어노테이션은 클래스 레벨 또는 메서드 레벨에 적용할 수 있습니다.
메서드 레벨에 적용된 @CrossOrigin
설정은 클래스 레벨의 설정을 오버라이드하며, 클래스 레벨의 설정은 전역 설정을 오버라이드합니다.
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
// 클래스 레벨: 이 컨트롤러의 모든 메서드에 기본 CORS 정책 적용
@CrossOrigin(origins = "http://localhost:3000", maxAge = 3600)
public class ProductController {
// GET 요청은 기본 정책을 따름
@GetMapping
public String getProducts() {
return "List of products";
}
// 메서드 레벨: PUT 요청에 대해서는 허용 메서드를 더 구체적으로 지정
@PutMapping("/{id}")
@CrossOrigin(origins = "http://localhost:3000", methods = {RequestMethod.PUT, RequestMethod.OPTIONS}, allowedHeaders = {"Content-Type", "Authorization"})
public String updateProduct(@PathVariable Long id) {
return "Product " + id + " updated";
}
// 이 메서드는 외부에 공개되지 않는 내부용 API이므로 CORS를 허용하지 않음
@PostMapping("/internal-process")
public String internalProcess() {
// @CrossOrigin 어노테이션이 없으므로 전역 설정이 적용되거나,
// 전역 설정도 없다면 CORS가 허용되지 않음.
return "Internal process finished";
}
}
@CrossOrigin
어노테이션은 WebMvcConfigurer
에서 설정했던 대부분의 속성(origins
, methods
, allowedHeaders
, allowCredentials
, maxAge
등)을 제공합니다. 특히 주목할 만한 속성은 exposedHeaders
입니다.
exposedHeaders
: 기본적으로 브라우저는 CORS 요청에 대한 응답에서Cache-Control
,Content-Language
,Content-Type
,Expires
,Last-Modified
,Pragma
와 같은 단순 응답 헤더만 클라이언트의 자바스크립트가 접근하도록 허용합니다. 만약 서버가JWT-Token
이나X-Total-Count
와 같은 커스텀 헤더를 응답에 포함했고, 클라이언트에서 이 값을 읽어야 한다면 서버는 응답에Access-Control-Expose-Headers
헤더를 통해 해당 헤더들을 명시적으로 노출시켜야 합니다.@CrossOrigin(exposedHeaders = {"JWT-Token", "X-Total-Count"})
와 같이 설정할 수 있습니다.
3.3. 필터 기반 설정: `CorsFilter`
스프링 프레임워크는 서블릿 필터(Filter)를 사용하여 CORS를 처리하는 저수준(low-level)의 방법도 제공합니다. 이 방식은 스프링 시큐리티와 함께 사용할 때 매우 유용하며, 요청 처리 파이프라인의 가장 앞단에서 CORS를 처리할 수 있게 해줍니다. WebMvcConfigurer
를 사용한 설정은 사실 내부적으로 이 필터 방식을 더 편리하게 설정하는 추상화된 방법입니다.
CorsFilter
를 직접 빈(Bean)으로 등록하여 더 세밀한 제어가 가능합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
@Configuration
public class CorsFilterConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// 자격 증명 허용
config.setAllowCredentials(true);
// 허용할 출처 패턴 설정 (allowedOrigins 대신 사용 권장)
config.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000", "https://*.my-frontend.com"));
// 허용할 헤더 설정
config.addAllowedHeader("*");
// 허용할 메서드 설정
config.addAllowedMethod("*");
// 특정 경로에 위 설정 적용
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
4. 스프링 시큐리티와 CORS 연동하기
스프링 시큐리티를 사용하는 프로젝트에서 CORS 문제를 겪는 경우가 매우 많습니다. 이는 스프링 시큐리티의 필터 체인과 CORS 처리 필터의 실행 순서 때문에 발생합니다. 스프링 시큐리티는 기본적으로 인증되지 않은 요청을 거부하는데, 만약 CORS 프리플라이트 요청(OPTIONS
)이 시큐리티 필터에 의해 먼저 차단된다면, 실제 CORS 처리 로직은 실행될 기회조차 갖지 못하고 요청이 실패하게 됩니다.
스프링 시큐리티 4.0 이상부터는 시큐리티 설정에 CORS를 통합하는 매우 간결한 방법을 제공합니다. 시큐리티 설정 클래스에서 http.cors()
를 호출하기만 하면 됩니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. CORS 설정 활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // 이 부분이 중요
// CSRF는 Stateless한 REST API에서는 보통 비활성화
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize
// 프리플라이트 요청은 인증 없이 허용
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
// 2. CORS 설정 소스를 별도의 Bean으로 정의
// WebMvcConfigurer 또는 CorsFilter Bean을 이미 등록했다면,
// 스프링 시큐리티는 해당 설정을 자동으로 사용하므로 이 Bean은 필요 없을 수 있다.
// 하지만 명시적으로 정의하는 것이 더 명확하다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
http.cors()
를 호출하면, 스프링 시큐리티는 애플리케이션 컨텍스트에서 `CorsConfigurationSource` 타입의 빈을 찾아 해당 설정을 사용하는 CorsFilter
를 자신의 필터 체인에 통합합니다. 이를 통해 시큐리티 검사 이전에 CORS 프리플라이트 요청이 정상적으로 처리될 수 있도록 보장합니다.
5. 일반적인 CORS 문제 해결 및 디버깅
CORS 오류 메시지는 항상 "Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy"와 같은 형태로 나타나기 때문에, 문제의 원인을 파악하기 어려울 수 있습니다. 효과적인 디버깅을 위한 몇 가지 팁입니다.
- 브라우저 개발자 도구 활용: 네트워크 탭을 열고 실패한 요청을 찾으세요. 만약 프리플라이트 요청(
OPTIONS
)이 있다면, 해당 요청과 응답 헤더를 먼저 확인해야 합니다.OPTIONS
요청이200 OK
가 아닌 다른 상태 코드(예:401 Unauthorized
,403 Forbidden
)로 실패했다면, 스프링 시큐리티와 같은 다른 필터가 CORS 처리 이전에 요청을 가로챘을 가능성이 높습니다. - 응답 헤더 확인:
OPTIONS
또는 실제 요청에 대한 서버의 응답 헤더에Access-Control-Allow-Origin
,Access-Control-Allow-Methods
등의 헤더가 기대한 값으로 포함되어 있는지 확인하세요. - `curl`을 이용한 서버 직접 테스트: 브라우저를 거치지 않고 서버의 CORS 정책을 직접 확인할 수 있습니다.
이 명령어를 통해 반환되는 응답 헤더를 보면 서버가 어떤 CORS 정책을 가지고 있는지 명확하게 알 수 있습니다.# 프리플라이트 요청 시뮬레이션 curl -v -X OPTIONS http://api.example.com/api/products/123 \ -H "Origin: http://localhost:3000" \ -H "Access-Control-Request-Method: PUT" \ -H "Access-Control-Request-Headers: Content-Type, Authorization"
- 자격 증명과 와일드카드 문제:
allowCredentials(true)
를 설정했다면,allowedOrigins("*")
는 절대 동작하지 않습니다. 반드시 특정 출처를 명시해야 합니다. 이는 가장 흔한 실수 중 하나입니다.
결론
교차 출처 리소스 공유(CORS)는 동일 출처 정책(SOP)이라는 웹의 근본적인 보안 모델을 안전하게 확장하기 위한 필수적인 메커니즘입니다. 처음에는 복잡하고 까다롭게 느껴질 수 있지만, 그 동작 원리, 특히 단순 요청과 프리플라이트 요청의 차이를 이해하면 대부분의 문제를 해결할 수 있습니다.
스프링 부트는 WebMvcConfigurer
를 통한 전역 설정, @CrossOrigin
을 통한 개별 설정, 그리고 CorsFilter
를 이용한 저수준 설정까지 다양한 수준의 유연성을 제공합니다. 프로젝트의 규모와 복잡도, 그리고 스프링 시큐리티와의 연동 여부를 고려하여 가장 적절한 방법을 선택하는 것이 중요합니다. CORS 오류를 만났을 때, 그것을 단순한 장애물로 여기기보다 웹의 보안 모델을 더 깊이 이해할 수 있는 기회로 삼는다면, 더욱 견고하고 안전한 웹 애플리케이션을 구축하는 데 큰 도움이 될 것입니다.
0 개의 댓글:
Post a Comment