왜 브라우저는 OPTIONS(Preflight) 요청을 보낼까? – CORS

2025-05-16T13:33:04.858+09:00 INFO 23918 --- [spring-login] [io-8080-exec-10] j.s.interceptor.LogInterceptor : REQUEST [3038b5a2-ce1b-492b-8f49-26ce2e81827e][/signup][org.springframework.web.servlet.handler.AbstractHandlerMapping$PreFlightHttpRequestHandler@74110b6]
2025-05-16T13:33:04.861+09:00 INFO 23918 --- [spring-login] [io-8080-exec-10] j.s.interceptor.LogInterceptor : RESPONSE [3038b5a2-ce1b-492b-8f49-26ce2e81827e][/signup]
2025-05-16T13:33:04.868+09:00 INFO 23918 --- [spring-login] [nio-8080-exec-1] j.s.interceptor.LogInterceptor : REQUEST [4d0a435c-7d92-4522-b0e6-979cd3cecec6][/signup][jiohh.springlogin.controller.LoginController#registerUser(SignUpRequestDto, HttpSession)]
2025-05-16T13:33:04.927+09:00 INFO 23918 --- [spring-login] [nio-8080-exec-1] j.s.controller.LoginController : Signup request: jiohh.springlogin.dto.SignUpRequestDto@6b564f31
2025-05-16T13:33:05.134+09:00 DEBUG 23918 --- [spring-login] [nio-8080-exec-1] org.hibernate.SQL : select u1_0.id,u1_0.name,u1_0.password,u1_0.role,u1_0.user_id from users u1_0 where u1_0.user_id=?
2025-05-16T13:33:05.183+09:00 INFO 23918 --- [spring-login] [nio-8080-exec-1] j.s.interceptor.LogInterceptor : RESPONSE [4d0a435c-7d92-4522-b0e6-979cd3cecec6][/signup]
로그인 signup 요청을 테스트 중 같은 요청이 두번 들어온것을 확인하였다. 해당 원인이 무엇인지에 대한 글이다.
Preflight 요청으로 부터 CORS 전반적인 내용을 다시 확인하는 과정을 거쳤다.
제목으로 나온 질문부터 시작하자면 결론적으로 CORS를 지원하기 위해 발생하는 preflight 요청이였다.
CORS Preflight란?
브라우저는 CORS 정책상 안전하지 않은 요청(POST, PUT, DELETE, 특정 headers 포함 등)을 보낼 때 실제 요청 전에 OPTIONS 요청을 서버에 보내 이 요청을 해도 되는지 확인 후 서버가 허용하면 요청을 보내게 된다.
즉 실제 요청은 다르지만(Post) 그 전에 브라우저가 OPTIONS로 허락을 요청한다.
브라우저에서 CORS에 대한 특정조건이라고 판단되는 경우 OPTIONS를 먼저 날려 확인하는 것이다.
그렇다면 Preflight 요청이 일어나는 조건은 무엇일까?
Preflight 요청
브라우저는 CORS 정책상 “단순 요청(Simple Request)“이 아닌 경우, Preflight 요청(OPTIONS) 을 먼저 보내게 된다.
- GET, HEAD, POST 요청이 아닐 경우
- Content-Type은 아래 중 하나가 아닐 경우
(application/x-www-form-urlencoded, multipart/form-data, text/plain) - 허용된 헤더 외의 헤더가 있으면 Preflight 발생
허용: Accept, Accept-Language, Content-Language, Content-Type (위 3개 중일 때만), Origin, Referer
다시 돌아보자면 CORS에 제대로 생각하지 못했던 것 같다.
다시 한번 CORS에 대해서 알아보자
먼저 SOP(Same Origin Policy)에 대해서 알아보자
"동일 출처 정책"으로 두 웹페이지(리소스)의 origin(출처)가 동일한 경우에만 접근을 가능하게하는 보안 정책이다.
하지만 경우에 따라 어쩔 수 없이 다른 출처 간의 상호작용을 해야하는 경우가 존재하며 이를 위한 정책이 CORS이다.
서로 다른 출처(origin)의 웹 페이지와 서버가 리소스를 주고받을 수 있게 도와주는 브라우저의 보안 정책이다.
백엔드와 프론트엔드의 출처(origin)이 다를 경우, 브라우저는 정보를 보호하기 위해 요청을 먼저 보내게 된다.
웹 보안의 기본 철학인 “내 웹사이트의 사용자가 모르게, 제3자 서버에 민감한 요청을 보내면 안 된다.”를 보장하기 위한 정책으로 내가 접속한 웹사이트가 아닌 타 서버로 유저가 모르게 정보를 보내는 것을 막아주는 역할을 한다.
브라우저는 서버를 대신하여 보안을 결정하지 않는 것이 핵심이다.
(왜 필요한지에 대해서는 다음글에 작성하고자 한다.)
CORS의 종류(동작방식)는 크게 3가지로 나눌 수 있다.
모두 Origin이 다를 경우 발생하는 요청이다.
-
Simple Request : get, post, head등 간단한 요청
간단한 요청일 경우 요청이 그대로 요청되게 되고 응답값에 따라서 브라우저에서 판단 후 조치되게 된다. -
Preflight Request : preflight가 발생하는 조건에 해당하는 요청
보안에 취약한 조건일 경우 Option으로 먼저 요청을 보내게된 후 안전할 경우 진짜 요청을 보내게된다. -
Credentialed request : 쿠키가 포함된 요청
Origin이 다를 경우 쿠키를 포함하여 요청하지 않음, credentials이 설정된 경우에 해당하는 요청 방식
그렇다면 Preflight 방식은 왜 사용할까?
Preflight 조건을 생각하면 답을 찾아볼 수 있다.
- GET, HEAD, POST 요청이 아닐 경우
- Content-Type이 일부 조건이 아닌경우(application/json)
해당 요청은 서버의 상태를 변경할 수 있는 요청들이며, Preflight 발생하여 요청을 처리할 수 있는지 먼저 확인하게 된다.
Simple 요청의 경우 일단 서버로 요청을 보내고 응답 헤더에 맞추어 처리하는 방식으로
Preflight 요청(서버의 상태를 변경할 수 있는)을 Simple 요청 방식처럼 처리할 경우 악의적으로 서버의 상태를 변경할 수 있게 된다.
악의적인 요청에 대해서 서버를 보호하는 역할도 수행하는 것이다.
끝내기 전에 CORS는 어떠한 과정을 거치게 될까?
실제 CORS 프로세스
브라우저는 프론트엔드 요청에 대해 Origin헤더를 추가하여 서버에 확인을 보낸 후
서버에서는 허용할지 안할지를 응답한 후 브라우저로 보내게 되고
브라우저에서 응답을 바탕으로 처리하게 된다.
다음이 아닌경우 CORS를 확인하게 된다.
즉 origin 헤더를 추가하여 브라우저가 서버에게 출저를 확인하게 한다.
- Same-origin인 경우
- 단순한 HTML 태그 요청인 경우(img, script, link 요청)
실제 요청과 응답은 다음과 같이 진행된다.
요청
OPTIONS /signup HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
응답
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 3600
- Origin에 원본 출처를 적어 서버로 요청을 보냄
- Simple request일 경우 요청을 그래도 전송
- preflight request일 경우 options으로 요청을 보내 확인하게됨
- 서버에서 요청 확인 후 처용가능한 출저를 “Access-Control-Allow-Origin”에 담아서 응답을 보냄
- 브라우저는 응답을 확인 후 어떻게 처리할지 결정하게됨
- 허용된 주소일 경우 - 응답을 확인 및 처리
- 허용되지 않은 주소일 경우 - 응답을 버리게된다.
결론적으로 우리가 봤던 요청은
Simple request에 해당하지않는 application/json 요청으로 인한 Preflight요청이였다.
그러면 Preflight요청은 어떻게 해야할까?
사실 어떻게 조치할 필요는 없다. 현재 필자는 Spring 프레임워크를 사용중인데 Spring의 경우 자동으로 처리하게 된다.
보통의 경우 CORS처리를 하면 자동으로 Preflight요청을 처리하게 된다.
필자는 로깅이 두번되는 상황으로 문제가 발생하였던 것으로 로깅 인터셉터에서 해당 로깅을 진행하지 않도록 분기문을 작성하면 되긴하나
사실 실제 테스트 환경이여서 그렇지 실제 서버가 운용되는 환경(Origin동일)이라면 크게 문제가 되지않을 것으로 예상된다.
다시 실행하면 Option요청이 발생하지 않던데?
로그를 다시 확인하기 위해 요청을 다시 보냈는데 Option요청이 다시 발생하지 않았다.
preflight 요청은 브라우저가 “캐싱”하게 되기 때문이였다.
서버 응답시 Access-Control-Max-Age: ??
위의 헤더 설정에 따라서 Options요청을 생략하게 된다.
정리
- 로깅에 찍히던 Options요청은 CORS Preflight 요청이였음
- CORS는 출처가 다를 경우 사용할 수 있는 보안 정책으로 Simple, Preflight, Credentiale 세가지 종류가 존재
- 그 중 Preflight 요청은 Option을 먼저 보내 요청을 처리할 수 있는지 서버에게 먼저 확인
- Preflight를 통해 악의적인 요청으로 서버의 상태가 변경되는 것을 막음
- Preflight 요청은 브라우저에서 캐싱을 하기 때문에 항상 보내지는 것은 아님