← wiki

프론트와 백엔드가 요청 & 응답을 주고받을 때

버튼을 누르면 목록이 갱신된다. 그 짧은 순간에 프론트엔드와 백엔드 사이에서는 정해진 순서대로 여러 일이 벌어진다. 이 글은 프론트엔드가 요청을 만드는 지점에서 출발해, 백엔드가 처리하고 응답을 돌려주고, 그 데이터가 다시 화면에 반영되기까지를 따라간다.

평소엔 신경 쓸 일이 없다. fetch 한 줄 쓰면 데이터가 알아서 오니까. 하지만 "요청은 분명 보냈는데 데이터가 안 와요", "로컬에선 되는데 배포하면 CORS 에러요", "왜 똑같은 요청인데 어떨 땐 빠르고 어떨 땐 느리죠?" 같은 문제를 만나는 순간, 이 사이에서 무슨 일이 벌어지는지 모르면 어디를 봐야 할지 감이 안 잡힌다. 그래서 이 흐름을 한 번 제대로 따라가 두면, 디버깅할 때 "지금 어느 단계가 문제지?" 를 짚을 수 있게 된다.

요청 생성프론트
캐시 확인ETag
네트워크DNS/TCP/TLS
CORSpreflight
백엔드 처리서버
응답status/body
상태 갱신프론트
리렌더화면
이 글의 예시

프론트엔드 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. 1
    요청 객체 조립

    URL, 메서드, 헤더, 본문을 하나의 요청으로 묶는다.

    GET은 보통 본문이 없고, POST/PUT은 자바스크립트 객체를 JSON.stringify()직렬화해 문자열 본문으로 만든다. 이때 Content-Type: application/json 헤더를 같이 붙여야 서버가 본문을 JSON으로 해석한다.

  2. 2
    인증 토큰 첨부

    로그인 때 받아둔 토큰을 Authorization 헤더에 싣는다.

    보통 Bearer <JWT> 형태로 보낸다. 토큰을 어디에 저장했느냐에 따라(메모리 / localStorage / 쿠키) 첨부 방식이 갈린다. 쿠키에 담았다면 브라우저가 자동으로 실어 보내기도 한다.

  3. 3
    로딩 상태 켜기

    요청을 보내기 직전 화면을 '로딩 중'으로 바꾼다.

    setLoading(true) 같은 상태 변경으로 스피너를 띄운다. 응답은 비동기로 도착하므로, 그 사이 사용자가 빈 화면을 보지 않도록 하는 게 프론트의 몫이다.

여기서 핵심은 요청과 응답이 동시에 일어나지 않는다는 점이다. fetch를 호출한 순간 자바스크립트는 응답을 기다리지 않고 다음 줄로 넘어간다 — 응답은 나중에 await/then을 통해 도착한다. 이 "기다림"이 있기 때문에 로딩 상태가 필요하고, 응답이 늦거나 실패할 경우까지 프론트가 대비해야 한다. 요청을 "보내고 끝"이 아니라 "보낸 뒤 결과를 받아 처리하는 한 사이클" 로 봐야 하는 이유다.


2. 보내기 전: 브라우저 캐시 확인

요청을 네트워크로 흘리기 전에, 브라우저는 "이거 전에 받은 적 있지 않나?"를 확인한다. 캐시가 살아 있으면 네트워크를 아예 타지 않는다. 가장 빠른 요청은 아예 보내지 않는 요청이기 때문이다. 이 캐시 검사는 프레임워크나 우리 코드와 무관하게 브라우저가 자동으로 한다.

  1. 1
    fresh면 캐시에서 바로

    Cache-Control 유효기간이 안 지났으면 네트워크 없이 끝.

    이전 응답에 Cache-Control: max-age=3600 이 있었다면, 1시간 안에는 서버에 가지 않고 캐시 값을 그대로 쓴다 (200 from cache).

  2. 2
    stale하면 재검증 (ETag → 304)

    유효기간이 지났으면 버리지 않고 '아직 그대로냐'고 묻는다.

    이전 응답의 ETag: "abc123" 을 다음 요청에 If-None-Match: "abc123" 으로 붙여 보낸다. 안 바뀌었으면 서버는 본문 없이 304 Not Modified 만 돌려주고, 브라우저는 캐시 본문을 재사용한다 — 다운로드 비용 0.

ETag

서버가 응답에 붙여주는 그 데이터의 버전 지문이다. 본문 내용으로 만든 짧은 식별자("abc123" 같은 값)라, 내용이 바뀌면 ETag도 바뀐다. 브라우저는 캐시한 ETag를 다음 요청에 If-None-Match 로 되보내 "내가 가진 버전 그대로냐"를 묻고, 같으면 서버는 본문 없이 304 만 돌려준다. 덕분에 안 바뀐 데이터를 다시 내려받지 않는다.

BrowserAPI Server
GET /users/42 + If-None-Match: "abc123"
304 Not Modified

본문 없음 → 캐시 재사용

정리하면 캐시는 두 갈래다. 아직 신선하면(fresh) 네트워크 없이 끝, 오래됐으면 (stale) 버리지 않고 "그대로냐"고 한 번 물어보는 재검증. 둘 다 불필요한 데이터 전송을 줄이려는 같은 목적이다. 캐시에 없거나 갱신이 필요하면, 이제 진짜로 백엔드까지 가야 한다.


3. 네트워크 계층 (요약)

프론트와 백엔드 사이에는 물리적인 네트워크가 있다. 요청 한 번 보내려면 이 준비 과정을 거치지만, 프론트 개발자 입장에서는 대부분 자동으로 일어난다. 그래도 한 번 짚고 가는 이유는, 이 계층이 "왜 첫 요청은 느리고 그다음은 빠른지" 를 설명해 주기 때문이다.

DNS이름 → IP
TCP3-way 연결
TLS암호화
  1. 1
    DNS — 이름을 IP로

    api.example.com 이라는 이름을 실제 서버 IP로 바꾼다.

    브라우저·OS 캐시를 먼저 보고, 없으면 리졸버가 대신 조회한다. 한 번 조회하면 한동안 캐시되므로 매 요청마다 일어나진 않는다.

  2. 2
    TCP — 연결 열기

    SYN / SYN-ACK / ACK 3번 인사로 연결을 성립시킨다.

    연결은 한 번 열면 재사용되므로, 같은 서버로 가는 다음 요청들은 이 과정을 건너뛴다(keep-alive).

  3. 3
    TLS — 암호화

    https니까 인증서를 검증하고 세션 키를 합의한다.

    서버 인증서가 진짜 그 도메인의 것인지 검증한 뒤, 이후 주고받을 데이터를 암호화할 키를 합의한다. 이것도 연결당 한 번이다.

프론트 입장에서 기억할 것

DNS · TCP · TLS는 연결 단위로 한 번 일어나고 재사용된다. 그래서 같은 API 서버로 보내는 두 번째, 세 번째 요청은 첫 요청보다 빠르다. 매 요청이 이 모든 걸 처음부터 하는 게 아니다.


4. CORS Preflight (다른 출처일 때)

여기가 프론트↔백 통신에서 가장 자주 막히는 지점이다. 프론트(app.example.com)와 API(api.example.com)의 출처(origin)가 다르기 때문에, 브라우저는 본 요청 전에 "이 요청 보내도 돼?"라고 먼저 물어본다.

먼저 오해부터 풀자. CORS는 브라우저가 강제하는 규칙이지, 보안 그 자체가 아니다. 같은 요청을 서버끼리(예: 백엔드 A가 백엔드 B를 호출)나 Postman으로 보내면 CORS 같은 건 없다. 오직 브라우저만, 자바스크립트가 자기와 다른 출처로 보내는 요청에 대해 이 검사를 건다. 출처(origin)란 프로토콜 + 도메인 + 포트 세 가지의 조합이라, 셋 중 하나만 달라도 "다른 출처"다 — httphttps, example.comapi.example.com, :3000:8080이 전부 별개로 취급된다.

왜 이런 게 있을까? 만약 이 검사가 없다면, 사용자가 악성 사이트에 접속한 순간 그 사이트의 자바스크립트가 사용자의 로그인 쿠키를 실어 은행 API로 마음대로 요청을 날릴 수 있다. CORS는 "이 출처에서 오는 요청을 허용한다"고 서버가 명시한 경우에만 브라우저가 응답을 자바스크립트에 넘겨주도록 막는 안전장치다.

언제 Preflight가 붙나

단순 요청(GET, 기본 헤더만)은 바로 보낸다. 하지만 Authorization 같은 커스텀 헤더가 있거나, PUT/DELETE/PATCH 거나, Content-Type: application/json 으로 본문을 보내면 — 본 요청 전에 OPTIONS 사전 요청이 자동으로 먼저 나간다.

BrowserAPI Server
OPTIONS /users/42

Preflight — 이 출처/메서드/헤더 허용돼?

204 + Access-Control-Allow-*

허용 목록 응답 (Origin, Methods, Headers)

GET /users/42

허락 떨어짐 → 진짜 요청

200 OK + JSON
흔한 CORS 에러

서버 응답에 Access-Control-Allow-Origin 헤더가 없거나 출처가 안 맞으면, 데이터는 서버에서 정상으로 왔어도 브라우저가 응답을 막는다. "서버는 200을 줬는데 프론트에서 CORS 에러"가 나는 이유다. 해결은 프론트가 아니라 백엔드의 CORS 설정에서 한다.


5. 백엔드: 요청 처리

요청이 서버에 도착했다. 보통 로드 밸런서·리버스 프록시를 거쳐 애플리케이션 코드에 닿고, 거기서 여러 관문을 순서대로 통과한다.

여기서 중요한 건 순서다. 이 관문들은 마치 공항 입국 심사처럼 줄지어 있어서, 앞 관문을 통과해야 다음으로 넘어간다. 인증이 라우팅보다 앞에 오는 건 의도된 설계다 — 토큰이 유효하지 않은 요청을 굳이 DB까지 끌고 갈 이유가 없으니, 되도록 일찍 걸러내는 게 서버 자원을 아끼는 길이다. 그래서 잘못된 요청일수록 짧은 경로로, 정상 요청일수록 긴 경로로 처리된다.

미들웨어전처리
인증JWT 검증
라우팅핸들러
DB 조회데이터
응답 조립JSON
  1. 1
    미들웨어 체인

    로깅 → 본문 파싱 → 인증 → 라우팅 순으로 요청을 가공한다.

    각 미들웨어는 요청을 검사·변형해 다음으로 넘기거나, 조건에 안 맞으면 여기서 끊고 에러를 반환한다. 예: 토큰이 없으면 핸들러까지 가지도 않고 401을 돌려준다.

  2. 2
    JWT 인증

    Authorization 헤더의 토큰 서명을 검증한다.

    JWT는 header.payload.signature 세 조각이다. 서버는 서명을 비밀키로 검증해 위조 여부만 보면 되므로, DB 조회 없이 무상태(stateless)로 인증된다. payload의 exp 로 만료도 확인한다.

  3. 3
    라우팅 + 비즈니스 로직

    경로에 맞는 핸들러가 요청을 처리한다.

    GET /users/:id 에 매핑된 핸들러가 실행돼, 권한을 확인하고 필요한 데이터를 모은다.

  4. 4
    DB 조회

    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. 프론트엔드: 응답 처리

응답이 도착했다. 프론트엔드는 이걸 받아 화면에 반영할 준비를 한다. 여기서 초보자가 가장 많이 놓치는 게 "응답이 왔다 ≠ 성공했다" 라는 점이다. 서버가 404500을 돌려줘도 그건 엄연히 정상적으로 도착한 응답이다. 그래서 받은 응답을 그냥 화면에 그리는 게 아니라, 상태 코드를 보고 성공·실패를 먼저 가른 다음 처리해야 한다.

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. 1
    상태 코드로 분기

    res.ok / res.status 로 성공·실패를 먼저 가른다.

    fetch 는 404·500에도 거부(reject)하지 않고 정상 resolve된다. 그래서 res.ok 를 직접 확인해 에러 분기를 해줘야 한다. 401이면 토큰을 갱신해 재시도하는 식의 처리도 여기서.

  2. 2
    본문 역직렬화

    JSON 문자열을 자바스크립트 객체로 되돌린다.

    res.json() 이 응답 본문 문자열을 파싱해 객체로 만든다. 1단계에서 프론트가 stringify 로 보냈던 것의 반대 과정이다.

  3. 3
    상태(state) 갱신

    받은 데이터를 컴포넌트 상태에 넣는다.

    setUser(user) 처럼 상태를 바꾸면, 프레임워크가 "이 상태를 쓰는 화면을 다시 그려야 한다"고 인지한다. 동시에 로딩 상태도 끈다.

fetch vs axios

위 "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만) 받아온 것도 이걸 위해서였다.

상태 변경setUser
리렌더컴포넌트
Virtual DOM diff비교
실제 DOM 갱신변경분만
SPA는 부분만 바꾼다

상태가 바뀐 컴포넌트가 새 화면(가상 DOM)을 만들고, 프레임워크가 이전 것과 달라진 부분만 비교(diff) 해 실제 DOM의 해당 부분만 고친다. 그래서 전체 새로고침 없이 목록 한 줄, 숫자 하나만 매끄럽게 바뀐다. 사용자가 보기엔 "버튼 눌렀더니 화면이 슥 바뀐" 것이지만, 그 뒤엔 지금까지의 8단계가 전부 돌아간 것이다.

프레임워크별 차이

가상 DOM diff는 React · Vue 계열의 방식이다. Svelte는 컴파일 단계에서 "어디가 바뀌면 DOM의 어디를 고칠지"를 미리 코드로 박아 가상 DOM을 아예 안 쓰고, Solid는 세밀한 반응성(fine-grained reactivity)으로 바뀐 값에 연결된 DOM 노드만 직접 갱신한다. 또 SPA가 아닌 전통적 MPA라면 이 단계가 통째로 "서버가 만든 새 HTML로 페이지를 교체"로 바뀐다. "바뀐 부분만 갱신"이라는 목표는 같고, 그걸 이루는 방식이 갈릴 뿐이다.


한 장 요약

  1. 1
    요청 생성

    프론트가 fetch로 URL·헤더·토큰·본문을 조립하고 로딩 ON.

  2. 2
    캐시 확인

    fresh면 끝. stale이면 ETag로 재검증(→ 304).

  3. 3
    네트워크

    DNS·TCP·TLS는 연결당 한 번, 재사용됨.

  4. 4
    CORS

    출처가 다르면 OPTIONS preflight가 먼저. 허용은 백엔드 설정.

  5. 5
    백엔드 처리

    미들웨어 → JWT 인증 → 라우팅 → DB 조회.

  6. 6
    응답

    상태 코드 + 헤더 + JSON 본문.

  7. 7
    응답 처리

    res.ok 분기 → json() 역직렬화 → 상태 갱신 → 로딩 OFF.

  8. 8
    리렌더

    가상 DOM diff로 바뀐 부분만 실제 DOM에 반영.