프론트와 백엔드가 요청 & 응답을 주고받을 때
버튼을 누르면 목록이 갱신된다. 그 짧은 순간에 프론트엔드와 백엔드 사이에서는 정해진 순서대로 여러 일이 벌어진다. 이 글은 프론트엔드가 요청을 만드는 지점에서 출발해, 백엔드가 처리하고 응답을 돌려주고, 그 데이터가 다시 화면에 반영되기까지를 따라간다.
평소엔 신경 쓸 일이 없다. fetch 한 줄 쓰면 데이터가 알아서 오니까. 하지만
"요청은 분명 보냈는데 데이터가 안 와요", "로컬에선 되는데 배포하면 CORS 에러요",
"왜 똑같은 요청인데 어떨 땐 빠르고 어떨 땐 느리죠?" 같은 문제를 만나는 순간,
이 사이에서 무슨 일이 벌어지는지 모르면 어디를 봐야 할지 감이 안 잡힌다.
그래서 이 흐름을 한 번 제대로 따라가 두면, 디버깅할 때 "지금 어느 단계가
문제지?" 를 짚을 수 있게 된다.
프론트엔드 app.example.com 에서, 백엔드 API api.example.com 으로
GET /users/42 를 보내 사용자 정보를 받아 화면에 그리는 시나리오를 따라간다.
REST + JSON + SPA 기준이다.
1. 프론트엔드: 요청 만들기
모든 건 프론트엔드 코드 한 줄에서 시작한다. 사용자가 버튼을 누르거나 페이지가 열리는 순간, 자바스크립트가 "이런 데이터를 달라"는 요청을 조립한다.
const res = await fetch("https://api.example.com/users/42", {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/json",
},
});
const user = await res.json();이 한 줄이 실행될 때 프론트엔드는 뒤에서 이런 일을 한다.
- 1요청 객체 조립
URL, 메서드, 헤더, 본문을 하나의 요청으로 묶는다.
GET은 보통 본문이 없고, POST/PUT은 자바스크립트 객체를
JSON.stringify()로 직렬화해 문자열 본문으로 만든다. 이때Content-Type: application/json헤더를 같이 붙여야 서버가 본문을 JSON으로 해석한다. - 2인증 토큰 첨부
로그인 때 받아둔 토큰을 Authorization 헤더에 싣는다.
보통
Bearer <JWT>형태로 보낸다. 토큰을 어디에 저장했느냐에 따라(메모리 / localStorage / 쿠키) 첨부 방식이 갈린다. 쿠키에 담았다면 브라우저가 자동으로 실어 보내기도 한다. - 3로딩 상태 켜기
요청을 보내기 직전 화면을 '로딩 중'으로 바꾼다.
setLoading(true)같은 상태 변경으로 스피너를 띄운다. 응답은 비동기로 도착하므로, 그 사이 사용자가 빈 화면을 보지 않도록 하는 게 프론트의 몫이다.
여기서 핵심은 요청과 응답이 동시에 일어나지 않는다는 점이다. fetch를 호출한
순간 자바스크립트는 응답을 기다리지 않고 다음 줄로 넘어간다 — 응답은 나중에
await/then을 통해 도착한다. 이 "기다림"이 있기 때문에 로딩 상태가 필요하고,
응답이 늦거나 실패할 경우까지 프론트가 대비해야 한다. 요청을 "보내고 끝"이 아니라
"보낸 뒤 결과를 받아 처리하는 한 사이클" 로 봐야 하는 이유다.
2. 보내기 전: 브라우저 캐시 확인
요청을 네트워크로 흘리기 전에, 브라우저는 "이거 전에 받은 적 있지 않나?"를 확인한다. 캐시가 살아 있으면 네트워크를 아예 타지 않는다. 가장 빠른 요청은 아예 보내지 않는 요청이기 때문이다. 이 캐시 검사는 프레임워크나 우리 코드와 무관하게 브라우저가 자동으로 한다.
- 1fresh면 캐시에서 바로
Cache-Control 유효기간이 안 지났으면 네트워크 없이 끝.
이전 응답에
Cache-Control: max-age=3600이 있었다면, 1시간 안에는 서버에 가지 않고 캐시 값을 그대로 쓴다 (200 from cache). - 2stale하면 재검증 (ETag → 304)
유효기간이 지났으면 버리지 않고 '아직 그대로냐'고 묻는다.
이전 응답의
ETag: "abc123"을 다음 요청에If-None-Match: "abc123"으로 붙여 보낸다. 안 바뀌었으면 서버는 본문 없이304 Not Modified만 돌려주고, 브라우저는 캐시 본문을 재사용한다 — 다운로드 비용 0.
서버가 응답에 붙여주는 그 데이터의 버전 지문이다. 본문 내용으로
만든 짧은 식별자("abc123" 같은 값)라, 내용이 바뀌면 ETag도 바뀐다.
브라우저는 캐시한 ETag를 다음 요청에 If-None-Match 로 되보내
"내가 가진 버전 그대로냐"를 묻고, 같으면 서버는 본문 없이 304 만
돌려준다. 덕분에 안 바뀐 데이터를 다시 내려받지 않는다.
본문 없음 → 캐시 재사용
정리하면 캐시는 두 갈래다. 아직 신선하면(fresh) 네트워크 없이 끝, 오래됐으면 (stale) 버리지 않고 "그대로냐"고 한 번 물어보는 재검증. 둘 다 불필요한 데이터 전송을 줄이려는 같은 목적이다. 캐시에 없거나 갱신이 필요하면, 이제 진짜로 백엔드까지 가야 한다.
3. 네트워크 계층 (요약)
프론트와 백엔드 사이에는 물리적인 네트워크가 있다. 요청 한 번 보내려면 이 준비 과정을 거치지만, 프론트 개발자 입장에서는 대부분 자동으로 일어난다. 그래도 한 번 짚고 가는 이유는, 이 계층이 "왜 첫 요청은 느리고 그다음은 빠른지" 를 설명해 주기 때문이다.
- 1DNS — 이름을 IP로
api.example.com 이라는 이름을 실제 서버 IP로 바꾼다.
브라우저·OS 캐시를 먼저 보고, 없으면 리졸버가 대신 조회한다. 한 번 조회하면 한동안 캐시되므로 매 요청마다 일어나진 않는다.
- 2TCP — 연결 열기
SYN / SYN-ACK / ACK 3번 인사로 연결을 성립시킨다.
연결은 한 번 열면 재사용되므로, 같은 서버로 가는 다음 요청들은 이 과정을 건너뛴다(keep-alive).
- 3TLS — 암호화
https니까 인증서를 검증하고 세션 키를 합의한다.
서버 인증서가 진짜 그 도메인의 것인지 검증한 뒤, 이후 주고받을 데이터를 암호화할 키를 합의한다. 이것도 연결당 한 번이다.
DNS · TCP · TLS는 연결 단위로 한 번 일어나고 재사용된다. 그래서 같은 API 서버로 보내는 두 번째, 세 번째 요청은 첫 요청보다 빠르다. 매 요청이 이 모든 걸 처음부터 하는 게 아니다.
4. CORS Preflight (다른 출처일 때)
여기가 프론트↔백 통신에서 가장 자주 막히는 지점이다. 프론트(app.example.com)와
API(api.example.com)의 출처(origin)가 다르기 때문에, 브라우저는 본 요청
전에 "이 요청 보내도 돼?"라고 먼저 물어본다.
먼저 오해부터 풀자. CORS는 브라우저가 강제하는 규칙이지, 보안 그 자체가
아니다. 같은 요청을 서버끼리(예: 백엔드 A가 백엔드 B를 호출)나 Postman으로
보내면 CORS 같은 건 없다. 오직 브라우저만, 자바스크립트가 자기와 다른 출처로
보내는 요청에 대해 이 검사를 건다. 출처(origin)란 프로토콜 + 도메인 + 포트
세 가지의 조합이라, 셋 중 하나만 달라도 "다른 출처"다 — http와 https,
example.com과 api.example.com, :3000과 :8080이 전부 별개로 취급된다.
왜 이런 게 있을까? 만약 이 검사가 없다면, 사용자가 악성 사이트에 접속한 순간 그 사이트의 자바스크립트가 사용자의 로그인 쿠키를 실어 은행 API로 마음대로 요청을 날릴 수 있다. CORS는 "이 출처에서 오는 요청을 허용한다"고 서버가 명시한 경우에만 브라우저가 응답을 자바스크립트에 넘겨주도록 막는 안전장치다.
단순 요청(GET, 기본 헤더만)은 바로 보낸다. 하지만 Authorization
같은 커스텀 헤더가 있거나, PUT/DELETE/PATCH 거나,
Content-Type: application/json 으로 본문을 보내면 — 본 요청
전에 OPTIONS 사전 요청이 자동으로 먼저 나간다.
Preflight — 이 출처/메서드/헤더 허용돼?
허용 목록 응답 (Origin, Methods, Headers)
허락 떨어짐 → 진짜 요청
서버 응답에 Access-Control-Allow-Origin 헤더가 없거나 출처가 안
맞으면, 데이터는 서버에서 정상으로 왔어도 브라우저가 응답을 막는다.
"서버는 200을 줬는데 프론트에서 CORS 에러"가 나는 이유다. 해결은 프론트가
아니라 백엔드의 CORS 설정에서 한다.
5. 백엔드: 요청 처리
요청이 서버에 도착했다. 보통 로드 밸런서·리버스 프록시를 거쳐 애플리케이션 코드에 닿고, 거기서 여러 관문을 순서대로 통과한다.
여기서 중요한 건 순서다. 이 관문들은 마치 공항 입국 심사처럼 줄지어 있어서, 앞 관문을 통과해야 다음으로 넘어간다. 인증이 라우팅보다 앞에 오는 건 의도된 설계다 — 토큰이 유효하지 않은 요청을 굳이 DB까지 끌고 갈 이유가 없으니, 되도록 일찍 걸러내는 게 서버 자원을 아끼는 길이다. 그래서 잘못된 요청일수록 짧은 경로로, 정상 요청일수록 긴 경로로 처리된다.
- 1미들웨어 체인
로깅 → 본문 파싱 → 인증 → 라우팅 순으로 요청을 가공한다.
각 미들웨어는 요청을 검사·변형해 다음으로 넘기거나, 조건에 안 맞으면 여기서 끊고 에러를 반환한다. 예: 토큰이 없으면 핸들러까지 가지도 않고 401을 돌려준다.
- 2JWT 인증
Authorization 헤더의 토큰 서명을 검증한다.
JWT는
header.payload.signature세 조각이다. 서버는 서명을 비밀키로 검증해 위조 여부만 보면 되므로, DB 조회 없이 무상태(stateless)로 인증된다. payload의exp로 만료도 확인한다. - 3라우팅 + 비즈니스 로직
경로에 맞는 핸들러가 요청을 처리한다.
GET /users/:id에 매핑된 핸들러가 실행돼, 권한을 확인하고 필요한 데이터를 모은다. - 4DB 조회
users/42 를 DB에서 읽어 응답 데이터를 조립한다.
6. 백엔드 → 프론트: HTTP 응답
처리가 끝나면 백엔드는 상태 코드 + 헤더 + 본문으로 응답을 돌려준다.
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: max-age=3600
ETag: "def456"
Access-Control-Allow-Origin: https://app.example.com
{ "id": 42, "name": "Zaman", "role": "student" }상태 코드는 프론트엔드가 응답을 어떻게 처리할지 분기하는 기준이 된다. 숫자
하나하나를 외울 필요는 없고, 앞자리로 범주를 읽으면 된다. 2xx는 성공,
3xx는 리다이렉트·캐시 관련, 4xx는 "요청한 쪽 잘못"(없는 주소, 권한 없음,
잘못된 입력), 5xx는 "서버 쪽 잘못". 이 구분이 중요한 이유는, 같은 실패라도
4xx면 프론트가 입력을 고쳐 다시 보내야 하고 5xx면 잠시 후 재시도하거나
에러를 알리는 게 맞기 때문이다. 즉 앞자리만 봐도 대응 방향이 정해진다.
- ·200 OK — 데이터 화면에 반영
- ·201 Created — 생성 성공 (POST)
- ·304 — 안 바뀜, 캐시 재사용
- ·401 — 토큰 만료 → 재로그인/갱신
- ·403 — 권한 없음 → 접근 차단 안내
- ·404 — 없음 → '데이터 없음' 표시
- ·500 — 서버 에러 → 에러 화면/재시도
- ·Content-Type — 본문 형식(JSON)
- ·Cache-Control / ETag — 다음 캐싱 규칙
- ·Access-Control-Allow-Origin — CORS 허용
- ·Set-Cookie — 세션/토큰 쿠키 심기
7. 프론트엔드: 응답 처리
응답이 도착했다. 프론트엔드는 이걸 받아 화면에 반영할 준비를 한다. 여기서
초보자가 가장 많이 놓치는 게 "응답이 왔다 ≠ 성공했다" 라는 점이다. 서버가
404나 500을 돌려줘도 그건 엄연히 정상적으로 도착한 응답이다. 그래서 받은
응답을 그냥 화면에 그리는 게 아니라, 상태 코드를 보고 성공·실패를 먼저
가른 다음 처리해야 한다.
if (!res.ok) {
// 4xx, 5xx 분기 처리
if (res.status === 401) return refreshTokenAndRetry();
throw new Error(`요청 실패: ${res.status}`);
}
const user = await res.json(); // 본문 역직렬화
setUser(user); // 상태 갱신
setLoading(false); // 로딩 끄기- 1상태 코드로 분기
res.ok / res.status 로 성공·실패를 먼저 가른다.
fetch는 404·500에도 거부(reject)하지 않고 정상 resolve된다. 그래서res.ok를 직접 확인해 에러 분기를 해줘야 한다. 401이면 토큰을 갱신해 재시도하는 식의 처리도 여기서. - 2본문 역직렬화
JSON 문자열을 자바스크립트 객체로 되돌린다.
res.json()이 응답 본문 문자열을 파싱해 객체로 만든다. 1단계에서 프론트가stringify로 보냈던 것의 반대 과정이다. - 3상태(state) 갱신
받은 데이터를 컴포넌트 상태에 넣는다.
setUser(user)처럼 상태를 바꾸면, 프레임워크가 "이 상태를 쓰는 화면을 다시 그려야 한다"고 인지한다. 동시에 로딩 상태도 끈다.
위 "fetch는 4xx · 5xx에도 reject하지 않는다"는 브라우저 내장 fetch 한정이다.
axios 같은 라이브러리는 4xx · 5xx면 자동으로 reject해서
try/catch 의 catch로 빠진다. 또 axios는 응답 JSON을 자동으로 파싱해
res.json() 호출이 따로 필요 없다. 도구가 달라도 "상태 코드로 성공·실패를
가르고, 본문을 객체로 되돌린다"는 할 일 자체는 같다.
8. 화면 반영 (리렌더)
상태가 바뀌면 프론트엔드 프레임워크가 화면을 갱신한다. 전체 페이지를 새로 받는 게 아니라, 바뀐 부분만 다시 그린다.
여기가 SPA(Single Page Application)가 옛날 방식과 갈라지는 지점이다. 전통적인
웹은 데이터가 바뀔 때마다 서버에서 HTML 페이지 전체를 새로 받아 화면을
통째로 다시 그렸다. 깜빡임이 있고 느렸다. SPA는 한 번 받은 화면을 자바스크립트가
들고 있다가, 새 데이터가 오면 그 데이터가 영향을 주는 부분만 골라 고친다.
그래서 "버튼 눌렀더니 목록 한 줄만 슥 바뀌는" 부드러운 경험이 가능해진다.
우리가 1단계에서 fetch로 데이터만(HTML이 아니라 JSON만) 받아온 것도 이걸
위해서였다.
상태가 바뀐 컴포넌트가 새 화면(가상 DOM)을 만들고, 프레임워크가 이전 것과 달라진 부분만 비교(diff) 해 실제 DOM의 해당 부분만 고친다. 그래서 전체 새로고침 없이 목록 한 줄, 숫자 하나만 매끄럽게 바뀐다. 사용자가 보기엔 "버튼 눌렀더니 화면이 슥 바뀐" 것이지만, 그 뒤엔 지금까지의 8단계가 전부 돌아간 것이다.
가상 DOM diff는 React · Vue 계열의 방식이다. Svelte는 컴파일 단계에서 "어디가 바뀌면 DOM의 어디를 고칠지"를 미리 코드로 박아 가상 DOM을 아예 안 쓰고, Solid는 세밀한 반응성(fine-grained reactivity)으로 바뀐 값에 연결된 DOM 노드만 직접 갱신한다. 또 SPA가 아닌 전통적 MPA라면 이 단계가 통째로 "서버가 만든 새 HTML로 페이지를 교체"로 바뀐다. "바뀐 부분만 갱신"이라는 목표는 같고, 그걸 이루는 방식이 갈릴 뿐이다.
한 장 요약
- 1요청 생성
프론트가 fetch로 URL·헤더·토큰·본문을 조립하고 로딩 ON.
- 2캐시 확인
fresh면 끝. stale이면 ETag로 재검증(→ 304).
- 3네트워크
DNS·TCP·TLS는 연결당 한 번, 재사용됨.
- 4CORS
출처가 다르면 OPTIONS preflight가 먼저. 허용은 백엔드 설정.
- 5백엔드 처리
미들웨어 → JWT 인증 → 라우팅 → DB 조회.
- 6응답
상태 코드 + 헤더 + JSON 본문.
- 7응답 처리
res.ok 분기 → json() 역직렬화 → 상태 갱신 → 로딩 OFF.
- 8리렌더
가상 DOM diff로 바뀐 부분만 실제 DOM에 반영.