웹 요청은 한 번의 함수 호출이 아니다
프론트엔드 코드에서 fetch("/api/users")를 호출하면 함수 하나를 실행한 것처럼 보인다. 하지만 실제로는 브라우저, DNS, TCP, TLS, HTTP, 서버 애플리케이션, 데이터베이스, 캐시가 차례로 관여한다. 지난 장에서 네트워크가 패킷을 목적지까지 보내는 과정을 보았다면, 이번 장에서는 그 위에서 웹 애플리케이션이 어떤 약속으로 대화하는지 살펴본다.
개발자가 웹 통신 구조를 알아야 하는 이유는 단순하다. 느린 응답, 로그인 풀림, CORS 오류, 캐시 때문에 바뀌지 않는 화면은 대부분 코드 한 줄만 보고 해결되지 않는다. 요청이 어디까지 갔는지, 브라우저가 막은 것인지, 서버가 거절한 것인지, 캐시가 오래된 응답을 돌려준 것인지 구분해야 한다.
웹에서 클라이언트와 서버는 주로 HTTP로 대화한다. HTTP는 "무엇을 요청했고, 결과가 무엇인지"를 표현하는 응용 계층 프로토콜이다. 브라우저는 HTTP 요청을 만들고, 서버는 HTTP 응답을 돌려준다. API도 결국 이 요청과 응답의 형식을 이용해 데이터를 주고받는다.
AI 생성 이미지
URL은 자원의 주소다
URL은 웹에서 자원을 찾기 위한 주소다. 예를 들어 다음 주소를 보자.
https://api.example.com:443/users/10?detail=truehttps는 사용할 프로토콜이다. api.example.com은 도메인 이름이고, DNS를 통해 IP 주소로 변환된다. 443은 포트다. HTTPS의 기본 포트이므로 보통 생략된다. /users/10은 서버 안에서 어떤 자원을 요청하는지 나타내는 경로다. ?detail=true는 쿼리 문자열이며, 필터나 옵션 같은 부가 정보를 전달할 때 자주 사용한다.
중요한 점은 URL이 "서버의 함수 이름"이 아니라는 것이다. REST 스타일 API에서는 URL을 자원 중심으로 설계한다. /users/10은 10번 사용자를 가리키고, 이 자원에 대해 어떤 일을 할지는 HTTP 메서드가 표현한다.
물론 실제 서버 내부에서는 이 경로가 특정 컨트롤러나 핸들러 함수로 연결될 수 있다. 다만 API를 설계하고 읽을 때는 "어떤 함수를 호출하느냐"보다 "어떤 자원을 어떤 의도로 다루느냐"를 먼저 생각하는 편이 좋다.
HTTP 메서드는 의도를 표현한다
HTTP 메서드는 요청의 의도를 나타낸다.
GET: 자원을 조회한다.POST: 새 자원을 만들거나 서버에 처리를 요청한다.PUT: 자원 전체를 교체한다.PATCH: 자원의 일부를 수정한다.DELETE: 자원을 삭제한다.
메서드는 단순한 이름 규칙이 아니다. 캐시, 재시도, 보안 정책에 영향을 준다. 예를 들어 GET은 보통 서버 상태를 바꾸지 않는 조회 요청으로 기대된다. 그래서 브라우저나 CDN이 캐시하기 쉽다. 반대로 POST는 서버 상태를 바꿀 수 있으므로 함부로 캐시하거나 자동 재시도하기 어렵다.
실무에서는 이 차이가 장애로 이어진다. 조회 API를 POST로만 만들면 중간 캐시를 활용하기 어렵다. 반대로 결제 요청처럼 부작용이 있는 작업을 재시도 가능하게 설계하지 않으면 네트워크 타임아웃 후 중복 결제가 발생할 수 있다. 이때는 멱등성 키 같은 별도 장치가 필요하다. 멱등성이란 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같게 유지되는 성질이다.
상태 코드는 결과의 분류다
HTTP 상태 코드는 서버가 요청을 어떻게 처리했는지 알려주는 숫자다. 모든 코드를 외울 필요는 없다. 먼저 큰 범주를 이해하면 된다.
2xx: 성공. 예:200 OK,201 Created,204 No Content3xx: 리다이렉션. 다른 위치로 이동해야 한다.4xx: 클라이언트 요청 문제. 예:400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found5xx: 서버 문제. 예:500 Internal Server Error,502 Bad Gateway,503 Service Unavailable
401과 403은 자주 혼동된다. 401은 인증이 필요하거나 인증 정보가 유효하지 않다는 뜻에 가깝다. "당신이 누구인지 확인할 수 없다"는 상황이다. 403은 인증은 되었지만 권한이 없다는 뜻에 가깝다. "누구인지는 알지만 이 작업은 허용되지 않는다"는 상황이다.
API 장애를 볼 때 상태 코드는 출발점이다. 404라면 경로, 라우팅, 배포 버전을 확인한다. 401이라면 쿠키나 토큰 전달 여부를 본다. 502라면 앞단 프록시나 게이트웨이가 뒤 서버와 통신하지 못했을 가능성을 본다. 상태 코드는 원인을 확정하지는 않지만, 탐색 방향을 좁혀 준다.
헤더와 바디는 메타데이터와 실제 내용이다
HTTP 메시지는 크게 시작 줄, 헤더, 바디로 구성된다. 헤더는 요청이나 응답에 대한 부가 정보다. 바디는 실제 데이터다.
요청 헤더에는 이런 정보가 들어갈 수 있다.
GET /users/10 HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer eyJ...Accept는 클라이언트가 어떤 응답 형식을 원하는지 말한다. Content-Type은 바디에 담긴 데이터 형식을 말한다. JSON을 보낼 때는 보통 Content-Type: application/json을 사용한다. Authorization은 토큰 같은 인증 정보를 보낼 때 사용한다. Cookie 헤더에는 브라우저가 해당 도메인에 저장한 쿠키가 담긴다.
응답도 헤더와 바디를 가진다.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60
{"id":10,"name":"Kim"}여기서 바디는 JSON 데이터이고, 헤더는 이 응답을 어떻게 해석하고 얼마나 캐시할 수 있는지 알려준다. API 오류를 디버깅할 때 응답 바디만 보면 놓치는 정보가 많다. 인증 실패, 캐시 정책, CORS 허용 여부, 압축 여부는 대부분 헤더에 나타난다.
HTTP는 기본적으로 상태를 기억하지 않는다
HTTP는 기본적으로 무상태(stateless) 프로토콜이다. 서버는 각각의 요청을 독립적으로 처리한다. 방금 로그인한 사용자가 다음 요청에서도 같은 사용자라는 사실은 HTTP 자체가 기억하지 않는다.
그래서 웹 애플리케이션은 별도의 방식으로 사용자 상태를 유지한다. 대표적인 방식이 쿠키, 세션, 토큰이다.
쿠키는 브라우저에 저장되는 작은 데이터다. 서버가 응답에 Set-Cookie 헤더를 넣으면 브라우저는 쿠키를 저장하고, 이후 같은 조건의 요청에 Cookie 헤더로 다시 보낸다. 쿠키는 자동 전송된다는 점이 편리하지만, 이 특성 때문에 CSRF 공격에 주의해야 한다. CSRF는 사용자가 로그인한 사이트에 대해 공격자가 원치 않는 요청을 대신 보내게 만드는 공격이다.
세션 방식에서는 서버가 사용자 상태를 저장한다. 로그인에 성공하면 서버는 세션 저장소에 사용자 정보를 만들고, 클라이언트에는 세션 ID만 쿠키로 보낸다. 이후 요청마다 브라우저가 세션 ID를 보내면 서버는 저장소에서 사용자를 찾는다. 이 방식은 서버가 세션을 직접 무효화하기 쉽다. 대신 서버가 상태를 들고 있으므로 서버가 여러 대일 때 세션 저장소 공유, 세션 복제, 로드밸런싱 정책을 고려해야 한다.
토큰 인증은 인증 결과를 토큰으로 만들어 클라이언트가 보관하고, 요청마다 보통 Authorization: Bearer ... 헤더에 담아 보내는 방식이다. 서버는 토큰을 검증해 사용자를 식별한다. 특히 JWT처럼 자체적으로 사용자 식별 정보와 만료 시간을 담는 토큰은 서버 세션 저장소 의존을 줄일 수 있다. 다만 탈취된 토큰은 만료 전까지 악용될 수 있고, 이미 발급한 토큰을 즉시 폐기하기 어렵다. 그래서 만료 시간을 짧게 두고, 재발급 토큰과 차단 목록 같은 보완책을 함께 사용한다.1
쿠키와 토큰 중 무엇이 무조건 더 안전하다고 말하기는 어렵다. 중요한 것은 저장 위치, 전송 방식, 만료 정책, HTTPS 사용, XSS와 CSRF 방어를 함께 설계하는 것이다. XSS는 공격자가 페이지에 악성 스크립트를 실행하게 만드는 공격이다. 토큰을 자바스크립트에서 접근 가능한 저장소에 넣으면 XSS에 취약해질 수 있다. 반대로 쿠키를 쓰면 HttpOnly, Secure, SameSite 같은 속성으로 위험을 줄여야 한다.
캐시는 빠르지만 오래된 응답을 만들 수 있다
웹 성능에서 가장 빠른 요청은 서버까지 가지 않는 요청이다. 캐시는 이전 응답을 저장해 두었다가 같은 요청에 재사용하는 장치다. 브라우저 캐시, 프록시 캐시, CDN 캐시, 서버 내부 캐시가 모두 여기에 속한다.
HTTP 캐시는 주로 응답 헤더로 제어한다.
Cache-Control: max-age=60
ETag: "user-10-v3"Cache-Control: max-age=60은 60초 동안 이 응답을 신선한 것으로 볼 수 있다는 뜻이다. ETag는 응답 버전을 나타내는 식별자다. 클라이언트는 다음 요청에서 "내가 가진 버전이 아직 유효한가?"를 물을 수 있고, 서버는 변경이 없으면 304 Not Modified를 돌려 바디 전송을 줄인다.
캐시는 성능을 크게 높인다. 정적 파일, 이미지, CSS, JavaScript 번들은 CDN에 올려 사용자와 가까운 위치에서 응답하게 만들 수 있다. CDN은 여러 지역의 엣지 서버에 콘텐츠를 저장해 원본 서버까지 가는 거리와 부하를 줄인다.
하지만 캐시는 문제도 만든다. 배포했는데 화면이 바뀌지 않거나, 사용자 정보가 섞여 보이거나, 로그아웃했는데 이전 응답이 남아 있는 문제가 생길 수 있다. 특히 개인화된 응답은 공유 캐시에 저장되지 않도록 주의해야 한다. 로그인 사용자별 데이터에는 보통 Cache-Control: private 또는 no-store 같은 정책을 검토한다. private은 공유 캐시에는 저장하지 말라는 뜻에 가깝고, no-store는 캐시 저장 자체를 피해야 할 때 사용한다.
정적 파일은 반대로 강하게 캐시할 수 있다. 파일 이름에 해시를 넣어 app.8f3a.js처럼 만들면 내용이 바뀔 때 URL도 바뀐다. 그러면 긴 캐시 시간을 설정해도 새 배포를 안전하게 반영할 수 있다.
압축과 Keep-Alive는 왕복 비용을 줄인다
웹 응답은 네트워크를 통해 이동하므로 크기가 작을수록 빠르다. 서버는 gzip, br, zstd 같은 압축을 사용해 텍스트 기반 응답의 크기를 줄일 수 있다. HTML, CSS, JavaScript, JSON은 압축 효과가 크다. 단, 이미 압축된 이미지나 영상은 다시 압축해도 이득이 작다.
압축은 전송량을 줄이는 대신 CPU를 사용한다. 그래서 대용량 트래픽 서비스에서는 압축 레벨, 응답 크기 기준, CDN 압축 지원 여부를 함께 본다. 무조건 최고 압축률을 쓰는 것이 정답은 아니다. CPU 병목이 심한 서버에서는 약한 압축이 전체 응답 시간을 더 줄일 수 있다.
Keep-Alive는 연결을 요청마다 새로 만들지 않고 재사용하는 방식이다. HTTP/1.1에서는 주로 TCP 연결을 재사용해 연결 수립 비용을 줄인다. 연결을 새로 만들 때는 TCP 연결 수립과 TLS 핸드셰이크 비용이 든다. 같은 서버에 여러 요청을 보낼 때 연결을 재사용하면 이 비용을 줄일 수 있다. HTTP/2는 하나의 연결 위에서 여러 요청과 응답을 더 효율적으로 처리하도록 발전했다. HTTP/3는 TCP 대신 QUIC을 사용하지만, 마찬가지로 연결 수립과 왕복 지연을 줄이는 방향으로 발전한 프로토콜이다.
실무에서 "API가 느리다"는 말은 여러 비용이 합쳐진 결과다. DNS, 연결 수립, TLS, 서버 처리, 데이터베이스 조회, 응답 압축, 다운로드, 브라우저 렌더링이 모두 포함된다. 개발자는 네트워크 탭이나 APM 도구로 어느 구간에서 시간이 쓰였는지 나누어 봐야 한다.
CORS는 서버 문제가 아니라 브라우저 보안 모델이다
CORS 오류는 초보자뿐 아니라 실무 개발자도 자주 만난다. 중요한 점은 CORS가 HTTP 자체의 제한이 아니라 브라우저의 보안 정책이라는 것이다. 서버끼리 직접 호출하거나 curl로 호출하면 되는데 브라우저에서만 실패하는 이유가 여기에 있다.
브라우저는 기본적으로 동일 출처 정책을 적용한다. 출처(origin)는 프로토콜, 호스트, 포트의 조합이다.
https://example.com
https://example.com:443
http://example.com
https://api.example.com이들은 비슷해 보여도 출처가 다를 수 있다. 예를 들어 https://example.com과 https://example.com:443은 HTTPS의 기본 포트가 443이므로 같은 출처로 보지만, http://example.com은 프로토콜이 다르고 https://api.example.com은 호스트가 다르므로 다른 출처다. 동일 출처 정책은 한 웹사이트의 스크립트가 다른 사이트의 민감한 응답을 마음대로 읽지 못하게 막는다. CORS는 이 제한을 완전히 없애는 기능이 아니라, 서버가 "이 출처의 브라우저 코드에는 응답을 읽게 해도 된다"고 명시적으로 허용하는 절차다.2
단순한 교차 출처 요청에서는 브라우저가 실제 요청에 Origin 헤더를 붙여 보낸다. 서버가 응답에 Access-Control-Allow-Origin을 적절히 내려주면 브라우저는 자바스크립트 코드가 응답을 읽도록 허용한다.
하지만 요청이 더 위험할 수 있다고 판단되면 브라우저는 먼저 OPTIONS 프리플라이트 요청을 보낸다. 프리플라이트는 "이 메서드와 헤더로 실제 요청을 보내도 되는가?"를 미리 묻는 절차다. 서버는 Access-Control-Allow-Methods, Access-Control-Allow-Headers 같은 헤더로 허용 범위를 답한다. 프리플라이트가 허용되지 않으면 브라우저는 실제 요청을 보내지 않는다. 단순 요청처럼 이미 실제 요청이 오간 경우라도 CORS 응답 헤더가 맞지 않으면 브라우저는 응답을 자바스크립트 코드에 노출하지 않는다.2
CORS 오류를 해결할 때는 다음 순서로 확인한다.
프론트엔드 주소와 API 주소의 출처가 같은지 확인한다.
브라우저 개발자 도구 콘솔에서 정확한 CORS 메시지를 읽는다.
네트워크 탭에서
OPTIONS요청이 있었는지 확인한다.서버 응답에
Access-Control-Allow-Origin이 있는지 확인한다.커스텀 헤더나
Authorization헤더를 쓴다면Access-Control-Allow-Headers에 포함되어 있는지 본다.쿠키를 포함하는 요청이라면 클라이언트의 credentials 설정과 서버의
Access-Control-Allow-Credentials: true를 함께 확인한다.
특히 쿠키를 포함한 교차 출처 요청에서는 Access-Control-Allow-Origin: *를 사용할 수 없다. 서버는 구체적인 출처를 응답해야 한다. 또한 브라우저에서 fetch를 사용할 때는 credentials: "include"를 설정해야 쿠키가 포함된다. 서버 설정만 바꾸고 클라이언트 설정을 놓치면 계속 로그인 세션이 전달되지 않는다.2
API 문제는 계층별로 좁혀야 한다
웹과 API 통신 문제를 해결할 때는 "서버가 안 된다"처럼 뭉뚱그려 보면 오래 걸린다. 계층별로 질문을 나누면 원인이 빨리 좁혀진다.
요청이 나갔는가? DNS와 네트워크 연결은 성공했는가? TLS 인증서 문제는 없는가? HTTP 상태 코드는 무엇인가? 인증 정보가 헤더나 쿠키에 실려 있는가? CORS 때문에 브라우저가 응답을 막은 것인가? 캐시가 오래된 응답을 돌려준 것인가? 서버 처리 시간이 긴가, 다운로드가 긴가?
이 질문들은 모두 HTTP의 구조에서 나온다. URL은 목적지를, 메서드는 의도를, 헤더는 조건과 메타데이터를, 바디는 실제 데이터를, 상태 코드는 결과 분류를 나타낸다. 쿠키와 세션과 토큰은 무상태 HTTP 위에서 사용자 상태를 유지하는 방법이다. 캐시와 CDN과 압축과 Keep-Alive는 같은 응답을 더 적은 비용으로 전달하기 위한 장치다. CORS는 브라우저가 사용자를 보호하기 위해 응답 접근을 제한하는 보안 모델이다.
웹 개발은 프레임워크 사용법만으로 완성되지 않는다. 브라우저와 서버가 어떤 약속으로 대화하는지 알아야 장애를 재현하고, 로그를 해석하고, 안전한 API를 설계할 수 있다.
참고
1HTTP Authorization methods: Sessions/Cookies, Bearer Tokens, API Keys, Signatures, Certificates
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.