배포 후 마주한 CORS와 쿠키 이슈, Spring Security로 해결

2026. 1. 15. 08:00·Backend Engineering/Java & Spring

지난 포스팅에서는 백엔드와 프론트엔드 플랫폼의 차이, 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 통신 필요 시

 

내가 마주한 딜레마

  1. 서로 다른 도메인(로컬 Front ↔ 배포 Back) 간에 쿠키를 공유하려면 SameSite=None이 필요하다.
  2. SameSite=None을 쓰려면 반드시 Secure=true (HTTPS) 속성이 함께 설정되어야 한다.
  3. "그런데 내 로컬 프론트는 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 코드는 상단 참조 ...
}

설정 포인트

  1. .csrf(disable): JWT를 사용하고 SessionCreationPolicy.STATELESS를 설정했으므로 CSRF 보호를 비활성화했다. (단, 쿠키를 사용할 경우 CSRF 공격 가능성이 완전히 0은 아니므로, 매우 민감한 정보 변경 시에는 별도의 CSRF 토큰이나 Referer 검증을 추가하는 것이 좋다.)
  2. .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
'Backend Engineering/Java & Spring' 카테고리의 다른 글
  • HttpServletRequest, 구식 기술일까? - 현업에서 만난 진실
  • Swagger 사용 중 415 에러(application/octet-stream) 해결 과정
  • Spring Boot JAR 배포 시 application.yml 설정 적용 우선순위
  • 파일 다운로드: POST 방식과 location.href의 차이점과 구현 방법
Dev히다
Dev히다
Java 백엔드 개발자입니다. 안정적인 서비스 운영과 효율적인 인프라 구축에 몰입합니다. 코드가 돌아가는 환경까지 이해하는 엔지니어를 지향합니다. Architecture, TroubleShooting, Tech Log.
  • Dev히다
    Java to Cloud : Dev Note
    Dev히다
  • 전체
    오늘
    어제
    • 분류 전체보기 (186)
      • AI & Future Tech (2)
        • AI Workspace (0)
        • AI Weekly News (0)
        • AI Agent & Automation (0)
        • LLM & RAG (2)
      • Backend Engineering (20)
        • Java & Spring (15)
        • JPA & QueryDSL (5)
      • Data Engineering (4)
        • DBMS & Tuning (3)
        • Redis & Cache (1)
      • Cloud & DevOps (5)
        • AWS Infrastructure (3)
        • Docker & CI CD (2)
      • Algorithm & CS (6)
        • CodingTest (5)
        • Computer Science (1)
      • Projects (12)
        • Side Project (10)
        • Work Experience (2)
      • Troubleshooting (9)
        • Error Log (0)
        • Review (9)
      • Log (0)
        • 내 맘대로 (0)
        • 여행 (0)
        • 요즘 (0)
      • Archive (125)
        • 기술면접 (33)
        • Project (9)
        • Spring (29)
        • Spring_Boot (2)
        • JAVA (5)
        • Servlet_JSP (12)
        • SQL (6)
        • JavaScript (1)
        • HTML_CSS (6)
        • Jquery (3)
        • Mybatis (1)
        • Vue.js (3)
        • 기타 (3)
        • 기타2 (2)
        • 코테대비 (10)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    인텔리제이
    폐쇄망
    CORS
    aws
    Join
    SQL
    docker
    자바
    대용량 트래픽
    토이프로젝트
    @Controller
    MVC
    redis
    프로그래머스
    AOP
    코딩테스트
    select
    기술 대비
    MVC2
    thread
    JSP
    @RestController
    Terraform
    인프런
    코테
    김영한
    프레임워크
    뉴렉처
    스프링
    공부기록
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
Dev히다
배포 후 마주한 CORS와 쿠키 이슈, Spring Security로 해결
상단으로

티스토리툴바