Project/TroubleShooting

항상 마주하는 CORS 에러: 여기 고치면 저기 문제, 저기 고치면 여기 문제

w.llama 2025. 1. 15. 10:13

프로젝트를 진행하면서 정말 자주 마주하는 문제 중 하나는 403 Forbidden 에러 그만 만나고싶은 403....
특히 CORS 정책으로 인해 발생하는 문제는 개발 환경과 배포 환경에서 다르게 동작할 때 더 힘들다. 이번 프로젝트에서는 Google API 통신과 JWT 기반 인증 시스템을 사용하면서도 CORS와 관련된 많은 문제를 해결했는데 이를 좀 정리해볼까 한다

프로젝트 배경

  • 백엔드: Java Spring Boot
  • 프론트엔드: React Native (Expo)
  • 데이터베이스: MySQL, MongoDB
  • 배포 환경: GCP VM 인스턴스 3대 (프론트엔드, 백엔드, 모니터링)
  • 모니터링 툴: Grafana, Prometheus
  • 인증 방식: JWT (Role 기반 접근 제어)

403 Forbidden 에러 발생 상황 및 주요 원인

  • . 배포 환경에서의 CORS 문제
    • 현상: 로컬 환경에서는 정상 작동하지만, 배포 환경에서는 다른 도메인 간 요청으로 인해 403 Forbidden 에러 발생.
    • 원인: Access-Control-Allow-Origin 헤더가 올바르게 설정되지 않아 CORS 정책 위반으로 API 요청 거부.
    2. CSRF 및 보안 설정 문제
    • Spring Security 기본 설정에서는 CSRF 보호 기능이 활성화되어 POST, PUT, DELETE 요청 시 CSRF 토큰이 필요함.
    • 그러나 JWT 인증 방식을 사용하기 때문에 CSRF 보호를 비활성화하도록 설정해야 함.
    3. JWT 인증 및 권한 문제
    • 인증이 필요한 API 호출 시 403 Forbidden 응답이 발생.
    • 원인:
      • JWT 토큰 만료 또는 잘못된 토큰 사용
      • 일반 사용자(ROLE_USER)가 관리자 전용 API에 접근할 때 발생

트러블 슈팅 과정

1. Spring Security 설정 조정

SecurityConfig 클래스의 설정을 통해 CORS, CSRF 및 인증 정책을 다음과 같이 구성:

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(cors -> cors.configurationSource(request -> {
                    CorsConfiguration config = new CorsConfiguration();
                    config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8080", "배포 주소"));
                    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
                    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept"));
                    config.setAllowCredentials(true);
                    return config;
                }))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // OPTIONS 메서드 허용
                        .requestMatchers("/auth/**", "/google/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);

        return http.build();
    }

2. CORS 설정 와일드카드 허용

모든 도메인을 허용해야 하는 경우 와일드카드 패턴을 사용:

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(cors -> cors.configurationSource(request -> {
                    CorsConfiguration config = new CorsConfiguration();
                    config.setAllowedOriginPatterns(List.of("*"));  // 와일드카드 패턴으로 모든 도메인 허용
                    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
                    config.setAllowedHeaders(List.of("*"));
                    config.setAllowCredentials(true);
                    return config;
                }))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**", "/google/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);

        return http.build();
    }

3. 환경 변수 및 배포 환경 설정

  • application-dev.properties: 개발용 설정
  • application-prod.properties: 배포용 설정
  • 배포 시 보안 강화를 위해 도메인 기반 CORS 정책과 환경 변수 설정을 분리하여 적용
  • 와일드카드로 설정할시 보안이 생명인 백엔드 서버에 모든 도메인을 설정하는것은 문제가 있다 개발할때는 괜찮지만 배포할때는 유의해야한다고 판단했다

추가적인 고려 사항

  1. 관리자 권한 및 JWT 토큰 처리
    • JWT에 ROLE_ADMIN을 포함하여, 관리자 여부를 쉽게 확인할 수 있도록 개선했습니다.
    • @AuthenticationPrincipal UserDetailsImpl userDetails를 사용해 API 호출 시 로그인한 사용자의 정보를 쉽게 조회 가능

@DeleteMapping("/delete/{id}")
    public ResponseEntity<CommonResponse<Void>> deleteBoard(
            @PathVariable String id,
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        if (!"ROLE_ADMIN".equals(userDetails.getRole())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                    .body(new CommonResponse<>("접근 권한이 없습니다.", HttpStatus.FORBIDDEN.value(), null));
        }
        boardService.deleteBoard(Long.valueOf(id));
        return ResponseEntity.ok(new CommonResponse<>("게시글이 성공적으로 삭제되었습니다.", HttpStatus.OK.value(), null));
    }

결론 및 배운 점

  • 개발 환경과 배포 환경에서의 CORS 문제는 서로 다른 설정으로 대응해야 함
  • Spring Security 설정 시, 인증 및 권한을 명확하게 구분하여 설정할 필요가 있음
  • JWT 기반 인증 시스템에서 토큰 만료 처리 및 권한 검증 로직이 중요함
  • .config체인 관련해서 로직을 조금 알고잇는것이 좋을거같다

403 Forbidden 에러는 설정해야 할 요소가 많지만, 명확한 원인 파악단계별 해결을 통해 충분히 해결할 수 있습니다. 이 트러블 슈팅 과정이 비슷한 문제를 겪는 개발자들에게 도움이 되길 바랍니다!