지난 포스팅에서는 백엔드와 프론트엔드 플랫폼의 차이, HTTPS의 중요성, 그리고 "Swagger는 잘 되는데 왜 프론트는 안 될까?"라는 의문까지 다뤘다.
핵심은 Origin(출처)의 차이였다.
- Swagger: https://my-backend.koyeb.app (Same-Origin) → 문제없음
- 로컬 프론트: http://localhost:3000 (Cross-Origin) → 브라우저가 차단
이론적으로는 모든 준비가 끝났다고 생각했지만, 막상 로컬 프론트엔드에서 배포된 백엔드로 요청을 보내자 브라우저 콘솔은 빨간색 에러로 도배되었다.
Access to fetch at 'https://api.myproject.koyeb.app/api/users'
from origin 'http://localhost:3000' has been blocked by CORS policy
Cookie "accessToken" has been rejected because it is in a cross-site
context and its "SameSite" is "Lax" or "Strict"
이번 글에서는 이 에러들을 하나씩 해결하며 완성한 Spring Security 설정 코드를 공유한다. 특히 HTTP(로컬)와 HTTPS(배포)가 혼재된 환경에서 인증을 처리해야 하는 분들에게 도움이 되길 바란다.
1. 첫 번째 관문: CORS (Cross-Origin Resource Sharing)
CORS가 뭐길래?
브라우저는 보안을 위해 다른 출처(Origin) 간의 리소스 공유를 기본적으로 차단한다. 이것이 바로 **Same-Origin Policy(동일 출처 정책)**이다.
- 내 상황:
- Front: http://localhost:3000 (로컬)
- Back: https://api.myproject.koyeb.app (배포)
- 결과: 프로토콜(http/https)도 다르고 도메인도 다르므로 Cross-Origin으로 판단되어 차단된다.
Preflight 요청 (OPTIONS)
실제로는 브라우저가 본 요청을 보내기 전에 "이 요청 보내도 돼?"라고 서버에 먼저 물어본다.
sequenceDiagram
participant Browser
participant Server
Note over Browser, Server: 1. Preflight (간보기)
Browser->>Server: OPTIONS /api/users
Server-->>Browser: 200 OK (Allowed-Origins, Methods, Credentials)
Note over Browser, Server: 2. Actual Request (본 요청)
Browser->>Server: POST /api/users (with Cookie)
Server-->>Browser: 200 OK (Data)
만약 1번 단계(Preflight)에서 적절한 CORS 헤더를 받지 못하면, 2번(본 요청)은 아예 전송되지도 않는다.
해결 코드: SecurityConfig.java
Spring Security 설정 파일(SecurityConfig.java)에서 CORS를 명시적으로 허용해줘야 한다.
기본 구조
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 1. 허용할 프론트엔드 도메인
configuration.setAllowedOrigins(List.of(
"http://localhost:3000",
"https://my-front.vercel.app"
));
// 2. 허용할 HTTP 메서드
configuration.setAllowedMethods(List.of(
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
));
// 3. 허용할 헤더
configuration.setAllowedHeaders(List.of("*"));
// 4. ⭐ 자격 증명 허용 (쿠키 전송 필수!)
configuration.setAllowCredentials(true);
// 5. Preflight 요청 캐싱 시간
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
실전 코드: 환경변수로 관리하기
하지만 위처럼 하드코딩하면 배포 환경마다 코드를 수정해야 한다. 실전에서는 환경변수로 관리하는 것이 좋다. application.yml이나 application.properties에 넣고 나서 설정 파일로 수정한다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 Origin (환경변수에서 주입)
configuration.setAllowedOrigins(allowedOrigins);
// 허용할 HTTP 메서드
configuration.setAllowedMethods(allowedMethods);
// 허용할 헤더
configuration.setAllowedHeaders(allowedHeaders);
// 쿠키 전송 허용
configuration.setAllowCredentials(allowCredentials);
// 브라우저가 응답 헤더를 읽을 수 있도록 노출
configuration.setExposedHeaders(exposedHeaders);
// Preflight 요청 캐싱 시간
configuration.setMaxAge(maxAge);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
2. 두 번째 관문: 쿠키 정책 (SameSite & Secure)
마주한 에러
CORS를 해결하고 나니 로그인은 성공한 것 같은데, 다음 요청부터 로그인이 풀리는(쿠키 미전송) 문제가 발생했다.
SameSite 속성이란?
쿠키의 SameSite 속성은 서로 다른 사이트 간에 쿠키를 전송할 것인지를 제어한다.
| 값 | 의미 | 사용 시나리오 |
| Strict | 같은 사이트에서만 쿠키 전송 | 보안 최우선 (은행 등) |
| Lax (기본) | 같은 사이트 + 안전한 요청(GET) | 일반적인 웹사이트 |
| None | 모든 사이트에서 쿠키 전송 | Cross-Site 통신 필요 시 |
내가 마주한 딜레마
- 서로 다른 도메인(로컬 Front ↔ 배포 Back) 간에 쿠키를 공유하려면 SameSite=None이 필요하다.
- SameSite=None을 쓰려면 반드시 Secure=true (HTTPS) 속성이 함께 설정되어야 한다.
- "그런데 내 로컬 프론트는 HTTP인데?"
localhost는 예외!
천만다행히도 최신 브라우저들은 개발 편의를 위해 localhost에 한해서는 HTTP 환경이라도 Secure 쿠키를 허용해준다.
- localhost (HTTP) + Secure 쿠키 = 허용 (개발 편의)
- 다른 도메인 (HTTP) + Secure 쿠키 = 차단
해결 코드: ResponseCookie 설정
구 버전의 Cookie 클래스 대신 ResponseCookie 빌더를 사용하면 속성을 섬세하게 조절할 수 있다.
public ResponseCookie createTokenCookie(String token) {
return ResponseCookie.from("accessToken", token)
.httpOnly(true) // JavaScript에서 접근 불가 (XSS 방지)
.secure(true) // HTTPS에서만 전송 (SameSite=None 시 필수)
.path("/") // 모든 경로에서 유효
.maxAge(60 * 60) // 1시간
.sameSite("None") // Cross-Site 요청에서도 전송 허용
.build();
}
// 컨트롤러 적용 예시
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginDto dto, HttpServletResponse response) {
String token = jwtService.generateToken(dto);
ResponseCookie cookie = createTokenCookie(token);
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok("로그인 성공");
}
3. 최종 보스: Spring Security FilterChain
위에서 만든 CORS 설정과 보안 정책을 Spring Security에 적용하는 최종 코드다. (Spring Security 6.x 기준)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. CSRF 비활성화 (REST API + JWT 토큰 방식)
.csrf(AbstractHttpConfigurer::disable)
// 2. CORS 설정 적용 (위에서 만든 Bean 주입)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 3. 세션 관리: Stateless (서버에 세션을 저장하지 않음)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 4. 보안 헤더 설정 (HSTS, XSS 방지 등)
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1년
)
)
// 5. 요청 경로별 권한 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Preflight 허용
.anyRequest().authenticated()
);
return http.build();
}
// ... corsConfigurationSource Bean 코드는 상단 참조 ...
}
설정 포인트
- .csrf(disable): JWT를 사용하고 SessionCreationPolicy.STATELESS를 설정했으므로 CSRF 보호를 비활성화했다. (단, 쿠키를 사용할 경우 CSRF 공격 가능성이 완전히 0은 아니므로, 매우 민감한 정보 변경 시에는 별도의 CSRF 토큰이나 Referer 검증을 추가하는 것이 좋다.)
- .requestMatchers(HttpMethod.OPTIONS, "/**"): Spring Security 필터 단계에서 Preflight 요청이 차단되지 않도록 OPTIONS 메서드를 명시적으로 허용해주는 것이 안전하다.
4. 트러블슈팅: 자주 마주치는 에러 체크리스트
혹시 아직도 빨간 에러가 뜬다면 아래 체크리스트를 확인해보자.
에러 1: "CORS policy: No 'Access-Control-Allow-Origin'..."
- SecurityConfig의 setAllowedOrigins에 프론트 도메인이 프로토콜, 포트까지 정확한가? (예: http://localhost:3000)
- setAllowCredentials(true)를 설정하고 allowedOrigins에 "*"를 쓰진 않았는가?
- requestMatchers에서 HttpMethod.OPTIONS를 허용했는가?
에러 2: "This Set-Cookie was blocked... SameSite..."
- ResponseCookie 생성 시 .sameSite("None")을 설정했는가?
- .secure(true)를 설정했는가? (None 설정 시 필수)
- 백엔드 서버가 HTTPS로 배포되었는가? (HTTP 배포 환경에서는 Secure 쿠키 동작 안 함)
에러 3: 로그인은 되는데 다음 요청부터 401 에러
- 프론트엔드 요청 코드에 credentials: 'include' (Fetch) 혹은 withCredentials: true (Axios)가 빠지지 않았는가?
이렇게 1편의 환경 이해부터 시작해 오늘 다룬 CORS와 쿠키 설정까지, 로컬 프론트엔드와 배포된 백엔드를 연결하는 긴 여정이 끝났다.
처음에는 "그냥 배포하면 되는 거 아니야?"라고 가볍게 생각했지만, 웹 브라우저가 우리의 보안을 위해 얼마나 깐깐하게 구는지 몸소 체험할 수 있었다. 이 설정들은 프로젝트가 커지거나 실제 도메인(mysite.com)을 연결하게 되면 또 달라질 수 있겠지만, 현재의 [로컬 개발 - 배포 서버] 하이브리드 환경에서는 가장 확실한 해결책이 될 것이다.
'Backend Engineering > Java & Spring' 카테고리의 다른 글
| HttpServletRequest, 구식 기술일까? - 현업에서 만난 진실 (0) | 2026.01.06 |
|---|---|
| Swagger 사용 중 415 에러(application/octet-stream) 해결 과정 (0) | 2026.01.05 |
| Spring Boot JAR 배포 시 application.yml 설정 적용 우선순위 (0) | 2025.02.19 |
| 파일 다운로드: POST 방식과 location.href의 차이점과 구현 방법 (0) | 2025.01.25 |
| 세션(Session)과 쿠키(Cookie)의 차이점과 사용 사례 (0) | 2025.01.16 |