LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

프론트엔드스터디APIHTTPRESTCORS프록시백엔드 협업데이터 계약

프론트엔드 스터디 대면 8주차: API와 통신 — 프론트엔드와 백엔드의 계약

2026년 5월 18일·47분 읽기

1. 이번 주 사전학습과 오늘 대면의 연결

이번 주 비대면에서는 클로저, Promise, async/await를 배웠습니다. 이 문법들이 실제 프로젝트에서 가장 자주 만나는 곳은 서버와 통신하는 코드 입니다. 버튼을 눌렀을 때 서버에 요청을 보내고, 응답을 기다리고, 성공하면 화면을 갱신하고, 실패하면 에러 메시지를 보여주는 모든 과정이 비동기 위에서 움직입니다.

오늘 대면의 목표는 fetch 사용법을 외우는 것이 아닙니다. API를 프론트엔드와 백엔드가 함께 지키는 계약으로 이해하고, 그 계약이 HTTP, CORS, 인증, API 클라이언트, TanStack Query, 파일 구조까지 어떻게 이어지는지 한 흐름으로 보는 것입니다.

처음 API를 보면 "주소로 요청을 보내면 JSON이 돌아온다" 정도로 느껴질 수 있습니다. 하지만 실무에서는 그보다 훨씬 많은 약속이 숨어 있습니다. 어떤 주소로 요청할지, 어떤 메서드를 쓸지, 어떤 데이터를 보낼지, 응답은 어떤 모양인지, 실패하면 어떤 코드가 오는지, 브라우저가 그 응답을 읽을 수 있는지까지 모두 맞아야 화면이 안정적으로 동작합니다.


2. API를 이해하기 전에 먼저 잡아야 하는 그림

2-1. 프론트엔드와 백엔드는 왜 나뉘어 있을까

웹 서비스를 아주 단순하게 보면 프론트엔드는 사용자가 보는 화면을 만들고, 백엔드는 데이터와 규칙을 관리합니다. 프론트엔드는 "스터디 목록을 보여줘야 한다"는 요구사항을 화면으로 표현하고, 백엔드는 "어떤 스터디가 있는지", "누가 참여할 수 있는지", "이미 마감됐는지" 같은 정보를 판단합니다.

text
사용자
  -> 브라우저에서 버튼 클릭
  -> 프론트엔드가 서버에 요청
  -> 백엔드가 데이터베이스와 비즈니스 규칙 확인
  -> 백엔드가 응답
  -> 프론트엔드가 응답을 화면으로 변환

즉 프론트엔드와 백엔드는 서로 떨어져 있지만 계속 대화합니다. 이 대화의 형식이 API입니다. API가 명확하면 화면과 서버가 독립적으로 개발될 수 있고, API가 모호하면 서로의 코드를 계속 추측해야 합니다.

2-2. API는 함수 호출처럼 보이지만 실제로는 네트워크 요청이다

프론트 코드에서는 getStudies() 같은 함수 하나로 보일 수 있습니다. 하지만 그 함수 안에서는 브라우저가 네트워크를 통해 서버에 HTTP 요청을 보냅니다. 네트워크는 항상 실패할 수 있습니다. 서버가 꺼져 있을 수 있고, 인터넷이 끊길 수 있고, 응답이 늦을 수 있고, 권한이 없을 수도 있습니다.

ts
const studies = await getStudies();
이 한 줄 안에는 실제로 다음 과정이 숨어 있습니다.
text
1. 요청 URL 만들기
2. HTTP 메서드 결정하기
3. 헤더와 body 구성하기
4. 브라우저가 요청 보내기
5. 서버가 응답하기
6. 브라우저가 CORS 등 보안 규칙 확인하기
7. JSON 파싱하기
8. 성공/실패에 따라 UI 상태 바꾸기

그래서 API 코드는 단순한 유틸 함수가 아니라, 사용자 경험과 백엔드 계약과 브라우저 보안 정책이 만나는 지점입니다.


3. HTTP — API 요청과 응답의 기본 언어

3-1. HTTP는 요청과 응답으로 움직인다

HTTP는 클라이언트가 요청하면 서버가 응답하는 규약입니다. 브라우저가 서버에 "이 데이터를 주세요"라고 말하면 서버가 "여기 있습니다" 또는 "안 됩니다"라고 답합니다.

http
GET /api/studies?page=1&size=20 HTTP/1.1
Host: example.com
Accept: application/json
http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [],
  "page": 1,
  "totalCount": 0
}

요청에는 보통 URL, 메서드, 헤더, body가 들어갑니다. 응답에는 상태 코드, 헤더, body가 들어갑니다. API를 설계한다는 것은 이 구성 요소들을 프론트엔드와 백엔드가 함께 정한다는 뜻입니다.

3-2. HTTP 메서드는 요청의 의도를 표현한다

API에서 GET, POST, PATCH, DELETE는 단순한 이름이 아니라 요청의 의도를 표현합니다. 프론트엔드와 백엔드가 이 의미를 공유해야 코드가 읽히고, 로그도 해석하기 쉬워집니다.

메서드보통의 의미예시
GET데이터 조회스터디 목록 조회
POST데이터 생성 또는 명령 실행스터디 신청
PUT전체 교체프로필 전체 수정
PATCH일부 수정닉네임만 수정

모든 팀이 완전히 같은 방식으로 쓰지는 않습니다. 중요한 것은 팀 안에서 일관된 규칙을 정하고 API 문서와 코드가 같은 의미를 가지게 만드는 것입니다.

3-3. 상태 코드는 응답의 큰 분류다

HTTP 상태 코드는 서버가 요청을 어떻게 처리했는지 알려주는 첫 번째 신호입니다. 프론트엔드는 상태 코드를 보고 성공, 입력 오류, 권한 오류, 서버 오류를 구분할 수 있어야 합니다.

상태 코드의미프론트엔드에서 자주 하는 처리
200성공데이터 표시
201생성 성공생성 완료 후 상세/목록 이동
204성공했지만 body 없음삭제 완료 처리
400잘못된 요청입력값 확인 메시지

여기서 중요한 점은 상태 코드만으로는 충분하지 않다는 것입니다. 400이라고만 내려오면 어떤 입력이 왜 잘못됐는지 알 수 없습니다. 그래서 에러 body의 모양도 API 계약에 포함되어야 합니다.

3-4. HTTP는 무상태다

HTTP의 중요한 특징은 무상태성입니다. 서버는 기본적으로 이전 요청을 자동으로 기억하지 않습니다. 방금 로그인했더라도 다음 요청에서 내가 누구인지 알려주지 않으면 서버는 그 요청을 익명 요청처럼 볼 수 있습니다.

http
GET /api/me HTTP/1.1
Authorization: Bearer access-token

그래서 로그인 이후 요청에는 쿠키나 토큰처럼 사용자를 식별할 정보가 함께 갑니다. 인증 이야기는 뒤에서 다시 다루지만, API와 인증은 분리해서 생각하기 어렵습니다. 데이터를 요청하는 순간 대부분 "누가 요청했는가"가 함께 문제가 되기 때문입니다.


4. API는 프론트엔드와 백엔드 사이의 계약이다

프론트엔드 입장에서 API는 서버에서 데이터를 받아오는 주소처럼 보입니다. 하지만 팀 관점에서 API는 훨씬 더 중요합니다. 데이터의 모양, 에러의 형태, 권한 규칙, 페이지네이션 방식, 정렬 방식, 캐싱 가능 여부가 모두 API에 담깁니다.

http
GET /api/studies?page=1&size=20

이 요청 하나에도 많은 약속이 들어 있습니다. page는 0부터 시작하는지 1부터 시작하는지, size 최대값은 얼마인지, 로그인이 필요한지, 실패하면 어떤 에러가 오는지, 빈 목록이면 어떤 응답이 오는지 정해야 합니다.

4-1. 응답 데이터의 모양

json
{
  "items": [
    {
      "id": 1,
      "title": "프론트엔드 스터디",
      "status": "OPEN",
      "currentMemberCount": 12,
      "maxMemberCount": 20
    }
  ],
  "page": 1,
  "size": 20,
  "totalCount": 42
}

프론트엔드는 이 응답을 화면으로 바꿉니다. 따라서 백엔드가 내려주는 필드 이름과 화면에서 필요한 정보가 맞아야 합니다. status: "OPEN"을 "모집 중"으로 보여줄지, 신청 버튼 노출 조건으로 쓸지, 마감 배지 색상으로 쓸지 같은 판단도 필요합니다.

여기서 필드 하나가 빠지면 프론트엔드는 임시로 값을 만들거나, 화면을 포기하거나, 백엔드에 다시 요청해야 합니다. 그래서 화면을 먼저 보고 "이 화면이 판단하려면 어떤 데이터가 필요한가"를 API 계약으로 바꾸는 능력이 중요합니다.

4-2. 요청 데이터의 모양

생성이나 수정 API에서는 프론트엔드가 서버로 데이터를 보냅니다. 이때도 계약이 필요합니다. 필수 필드는 무엇인지, 문자열 길이 제한은 어디까지인지, 날짜 형식은 무엇인지, 빈 문자열과 null을 어떻게 다루는지 정해야 합니다.

json
{
  "title": "프론트엔드 스터디",
  "description": "API와 통신을 공부합니다.",
  "maxMemberCount": 20
}

프론트엔드는 이 계약을 기준으로 폼 검증을 만들고, 백엔드는 같은 계약을 기준으로 서버 검증을 수행합니다. 프론트 검증은 사용자 경험을 좋게 만들고, 백엔드 검증은 데이터 무결성을 지킵니다. 둘 중 하나만 있으면 충분하지 않습니다.

4-3. 에러 응답의 형태

성공 응답보다 더 중요한 것이 에러 응답입니다. 실제 서비스에서는 실패가 자주 일어납니다. 네트워크가 끊기고, 권한이 없고, 입력값이 틀리고, 서버가 죽습니다.

json
{
  "code": "STUDY_ALREADY_CLOSED",
  "message": "이미 마감된 스터디입니다.",
  "fieldErrors": []
}

에러가 이렇게 내려오면 프론트엔드는 사용자에게 적절한 메시지를 보여줄 수 있습니다. 반대로 모든 에러가 그냥 500으로만 내려오면, 화면에서는 "알 수 없는 오류" 말고 할 수 있는 게 없습니다.

특히 폼에서는 필드별 에러가 중요합니다. title이 비었는지, maxMemberCount가 너무 큰지, 이미 사용 중인 이름인지에 따라 보여줄 메시지와 포커스 위치가 달라집니다.

json
{
  "code": "VALIDATION_ERROR",
  "message": "입력값을 확인해주세요.",
  "fieldErrors": [
    {
      "field": "title",
      "message": "제목은 필수입니다."
    }
  ]
}

4-4. 빈 데이터와 권한 없음은 다르다

API 협업에서 자주 빠지는 것이 빈 상태입니다. 데이터가 없을 때 빈 배열을 줄지, null을 줄지, 404로 볼지, 정상 응답으로 볼지 합의해야 합니다.

text
검색 결과 없음        -> 200 OK + items: []
아직 작성한 글 없음   -> 200 OK + items: []
존재하지 않는 글       -> 404 Not Found
권한이 없어 볼 수 없음 -> 403 Forbidden
로그인이 필요함       -> 401 Unauthorized

이들은 모두 화면이 다릅니다. "데이터가 없는 것"과 "볼 수 없는 것"과 "존재하지 않는 것"을 같은 에러로 처리하면 사용자는 지금 무엇을 해야 하는지 알 수 없습니다.

4-5. 페이지네이션과 정렬

목록 API에서는 페이지네이션이 거의 항상 등장합니다. 이때 offset 방식인지 cursor 방식인지, 정렬 기준은 무엇인지, 다음 페이지가 있는지 어떻게 알려줄지 정해야 합니다.

페이지네이션이 필요한 이유는 단순합니다. 서버에 데이터가 10,000개 있다고 해서 화면이 처음 뜰 때 10,000개를 모두 내려주면 느리고 비효율적입니다. 그래서 보통 "한 번에 20개만 주세요"처럼 데이터를 잘라서 가져옵니다. 이때 데이터를 자르는 기준을 어떻게 잡을지가 offset 방식과 cursor 방식의 차이입니다.

offset 방식 — 몇 개를 건너뛸지로 요청하기

offset 방식은 "앞에서 몇 개를 건너뛰고 몇 개를 가져올지"로 목록을 요청합니다. 페이지 번호 기반 API도 보통 이 방식으로 이해할 수 있습니다.

http
GET /api/posts?page=3&size=20
또는 더 직접적으로 이렇게 표현하기도 합니다.
http
GET /api/posts?offset=40&limit=20

이 요청은 "앞의 40개는 건너뛰고, 그 다음 20개를 주세요"라는 뜻입니다. 그래서 offset 방식은 구현과 이해가 쉽습니다. 관리자 페이지처럼 1페이지, 2페이지, 3페이지 버튼이 있고, 사용자가 특정 페이지로 바로 이동해야 하는 화면에서는 자연스럽습니다.

하지만 데이터가 계속 추가되거나 삭제되는 목록에서는 문제가 생길 수 있습니다. 예를 들어 사용자가 첫 번째 요청으로 최신 글 20개를 보고 있는 동안, 다른 사람이 새 글 3개를 올렸다고 해봅시다. 사용자가 다음 페이지를 요청할 때 서버는 다시 "앞의 20개를 건너뛰고 다음 20개"를 계산합니다. 그런데 그 사이 목록 앞쪽에 새 글 3개가 끼어들었기 때문에, 사용자는 이미 봤던 글을 다시 보거나 중간 글을 건너뛸 수 있습니다.

text
처음 요청 시점
[100, 99, 98, ... 81]  -> 1페이지에서 봄
[80, 79, 78, ... 61]   -> 다음에 볼 예정

그 사이 새 글 3개 추가
[103, 102, 101, 100, 99, 98, ...]

offset=20으로 다음 요청
원래 기대: 80부터
실제 결과: 83부터 시작할 수 있음

이런 현상은 무한 스크롤에서 특히 거슬립니다. 사용자는 "아래로 계속 이어지는 하나의 목록"을 기대하는데, offset이 중간에 흔들리면 같은 항목이 다시 나오거나 일부 항목이 빠진 것처럼 보입니다.

cursor 방식 — 마지막으로 본 지점 이후를 요청하기

cursor 방식은 "몇 개를 건너뛸지"가 아니라 "내가 마지막으로 본 항목 이후를 주세요"라고 요청합니다. 예를 들어 최신순으로 글을 보고 있고, 마지막으로 본 글의 id가 81이라면 다음 요청은 이런 느낌이 됩니다.

http
GET /api/posts?cursor=81&limit=20

실제 API에서는 cursor가 단순한 id일 수도 있고, 정렬 기준과 id를 함께 담은 문자열일 수도 있습니다. 그래서 응답에 nextCursor처럼 다음 요청에 그대로 넣을 값을 내려주는 경우가 많습니다.

json
{
  "items": [],
  "nextCursor": "eyJpZCI6MTAwfQ==",
  "hasNext": true
}

작은 프로젝트에서는 별것 아닌 것처럼 보여도, 무한 스크롤이나 검색 필터가 붙으면 이 결정이 사용자 경험과 성능을 크게 좌우합니다. 페이지 번호 기반은 구현이 쉽고 특정 페이지로 이동하기 좋습니다. 반면 cursor 기반은 데이터가 계속 추가되는 피드, 댓글, 알림, 채팅, 활동 로그처럼 "지금 본 다음부터 자연스럽게 이어서 보기"가 중요한 목록에서 더 안정적인 경우가 많습니다.

정리하면 offset은 위치 숫자를 기준으로 자르고, cursor는 마지막으로 본 데이터의 기준점을 중심으로 이어서 가져옵니다. 그래서 API를 설계할 때는 단순히 "목록을 몇 개씩 줄까요?"만 정하는 것이 아니라, 이 목록이 페이지 이동 중심인지, 무한 스크롤 중심인지, 데이터가 자주 추가되는지까지 함께 생각해야 합니다.


5. 브라우저 보안 정책을 이해해야 CORS가 보인다

API 호출을 배우다 보면 CORS 에러를 거의 반드시 만납니다. 처음 보면 서버가 고장난 것처럼 보이지만, CORS는 서버 에러라기보다

브라우저가 응답을 JavaScript에 넘겨도 되는지 판단하는 보안 규칙

입니다.

CORS를 이해하려면 먼저 세 단어를 잡아야 합니다. Origin, SOP, Preflight입니다.

5-1. Origin — 출처는 URL 전체가 아니다

Origin은 보통 프로토콜, 호스트, 포트의 조합입니다. 경로(path)나 쿼리스트링은 Origin에 포함되지 않습니다.

text
https://example.com:443/posts/1?tab=comment
└────┬────┘ └────┬────┘ └┬┘
  protocol      host    port

Origin = https://example.com:443

다음 두 주소는 같은 Origin입니다. 경로만 다르기 때문입니다.

text
https://example.com/posts
https://example.com/api/studies
반면 아래 주소들은 서로 다른 Origin입니다.
text
http://localhost:5173
http://localhost:8080

https://example.com
https://api.example.com

http://example.com
https://example.com

localhost라는 이름이 같아도 포트가 다르면 다른 출처입니다. 도메인이 비슷해도 www.example.com과 api.example.com은 호스트가 다르기 때문에 다른 출처입니다. http와 https도 프로토콜이 다르므로 다른 출처입니다.

5-2. SOP — 같은 출처 정책

SOP는 Same-Origin Policy, 즉 같은 출처 정책입니다. 브라우저는 기본적으로 한 출처에서 실행된 JavaScript가 다른 출처의 응답을 마음대로 읽지 못하게 막습니다.

왜 이런 정책이 필요할까요? 사용자가 어떤 사이트에 로그인해 있다고 가정해봅시다. 악성 사이트가 사용자의 브라우저에서 몰래 은행 사이트나 사내 시스템으로 요청을 보내고 응답을 읽을 수 있다면 큰 문제가 됩니다. 브라우저는 이런 상황을 막기 위해 "다른 출처의 응답을 JavaScript가 읽는 것"을 기본적으로 제한합니다.

여기서 중요한 점은 요청 자체와 응답 읽기는 다르다는 것입니다. 브라우저가 요청을 아예 보내지 않는 경우도 있지만, 많은 경우 서버까지 요청은 도착합니다. 다만 서버 응답에 적절한 CORS 허가 헤더가 없으면 브라우저가 그 응답을 프론트엔드 JavaScript에 넘기지 않습니다.

text
프론트 코드: fetch("http://localhost:8080/api/studies")
브라우저: 요청은 보냄
서버: 응답은 보냄
브라우저: 응답 헤더 확인
브라우저: 허용되지 않은 Origin이면 JS 코드에 응답을 넘기지 않음

5-3. CORS — 다른 출처 접근을 허용하는 예외 규칙

CORS는 Cross-Origin Resource Sharing의 줄임말입니다. 이름 그대로 다른 출처의 리소스를 공유하기 위한 규칙입니다. SOP가 기본적으로 막는다면, CORS는 서버가 "이 Origin은 내 응답을 읽어도 된다"고 브라우저에게 알려주는 방식입니다.

서버가 내려주는 대표적인 헤더는 Access-Control-Allow-Origin입니다.

http
Origin: http://localhost:5173
Access-Control-Allow-Origin: http://localhost:5173

브라우저는 요청에 담긴 Origin과 응답의 Access-Control-Allow-Origin을 비교합니다. 값이 맞으면 응답을 JavaScript에 넘깁니다. 값이 없거나 맞지 않으면 네트워크 요청이 성공했더라도 프론트엔드 코드에서는 응답을 사용할 수 없습니다.

5-4. CORS 에러는 왜 서버 로그에는 성공처럼 보일 수 있을까

CORS 에러가 헷갈리는 이유는 서버 입장에서는 요청을 정상 처리했을 수 있기 때문입니다. 서버 로그에는 200이 찍혔는데 브라우저 콘솔에는 CORS 에러가 보이는 상황이 가능합니다.

text
서버 관점: 요청 받음 -> 응답 보냄 -> 200 OK
브라우저 관점: 응답 받음 -> CORS 헤더 확인 실패 -> JS에 응답 전달 차단
프론트 관점: fetch 실패처럼 보임

그래서 CORS 문제를 볼 때는 서버 로그만 보면 안 됩니다. 브라우저 개발자 도구의 Network 탭에서 요청 헤더의 Origin, 응답 헤더의 Access-Control-Allow-Origin, OPTIONS 요청의 응답을 같이 확인해야 합니다.


6. Preflight — 실제 요청 전에 보내는 사전 확인

6-1. 단순 요청과 사전 요청

모든 CORS 요청이 Preflight를 보내는 것은 아닙니다. 브라우저는 비교적 단순한 요청은 바로 보내고, 위험할 수 있거나 서버 상태를 바꿀 가능성이 있는 요청은 먼저 OPTIONS 요청으로 허락을 구합니다. 이 사전 확인을 Preflight라고 부릅니다.

http
OPTIONS /api/studies HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

이 요청은 "내가 이 Origin에서 POST를 보낼 건데, content-type과 authorization 헤더를 써도 되나요?"라고 미리 묻는 요청입니다. 서버가 허용한다고 답하면 브라우저가 실제 POST 요청을 보냅니다.

6-2. 서버는 Preflight에 무엇을 답해야 하나

서버는 Preflight 요청에 허용할 Origin, Method, Header를 응답해야 합니다.

http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

여기서 Access-Control-Max-Age는 Preflight 결과를 브라우저가 얼마나 캐싱할 수 있는지 알려줍니다. 매 요청마다 OPTIONS가 보이면 네트워크가 지저분해 보일 수 있는데, 이 캐시 설정이 있으면 일정 시간 동안 사전 요청을 줄일 수 있습니다.

6-3. Preflight를 자주 유발하는 것들

실무에서 Preflight를 자주 보게 되는 이유는 우리가 보내는 API 요청이 단순 요청이 아닌 경우가 많기 때문입니다.

  • Content-Type: application/json으로 POST/PATCH를 보내는 경우
  • Authorization 헤더를 붙이는 경우
  • 커스텀 헤더를 붙이는 경우
  • PUT, PATCH, DELETE 같은 메서드를 쓰는 경우
  • 쿠키나 인증 정보를 포함하는 요청을 보내는 경우

Preflight 자체는 문제가 아닙니다. 브라우저가 서버에 미리 확인하는 정상적인 절차입니다. 문제는 서버가 OPTIONS 요청을 처리하지 못하거나, 허용 헤더를 빠뜨리거나, 실제 요청과 Preflight 요청의 CORS 설정이 다를 때 발생합니다.

6-4. 인증 정보가 포함된 CORS 요청

쿠키를 포함하는 요청은 더 엄격합니다. 프론트엔드에서 credentials: "include"를 설정하거나 axios에서 withCredentials: true를 설정해야 하고, 서버도 Access-Control-Allow-Credentials: true를 내려줘야 합니다.

ts
await fetch("https://api.example.com/me", {
  credentials: "include",
});
http
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true

이때 Access-Control-Allow-Origin: *는 사용할 수 없습니다. 인증 정보를 포함한 요청에서는 서버가 허용할 Origin을 구체적으로 적어야 합니다. 모든 출처에 쿠키 포함 응답을 열어버리면 보안 경계가 무너질 수 있기 때문입니다.


7. CORS를 해결하는 방법 — 서버 설정과 프록시

7-1. 정석은 서버가 허용할 출처를 명시하는 것

가장 정석적인 해결은 백엔드가 실제로 허용할 프론트엔드 출처를 응답 헤더에 명시하는 것입니다.

http
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

개발 환경과 운영 환경은 보통 Origin이 다릅니다. 로컬에서는 http://localhost:5173, 운영에서는 https://www.example.com일 수 있습니다. 백엔드는 이 둘을 구분해서 허용해야 합니다.

ts
// NestJS 예시
app.enableCors({
  origin: ["http://localhost:5173", "https://www.example.com"],
  credentials: true,
});

운영 환경에서 무작정 모든 Origin을 여는 것은 피해야 합니다. CORS는 귀찮은 에러가 아니라 "어떤 웹사이트가 이 응답을 읽을 수 있는가"를 정하는 보안 경계입니다.

7-2. 프론트 개발 서버 프록시

개발 환경에서는 프론트 개발 서버가 백엔드로 요청을 대신 전달하는 프록시를 자주 씁니다. 브라우저는 같은 출처인 프론트 개발 서버에 요청한다고 느끼고, 프론트 개발 서버가 뒤에서 백엔드 서버로 요청을 넘깁니다.

ts
// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
      },
    },
  },
});
ts
// 브라우저에서는 같은 출처로 요청하는 것처럼 보인다.
await fetch("/api/studies");

원리는 단순합니다. CORS는 브라우저가 다른 출처의 응답을 JavaScript에 넘길지 판단하는 규칙입니다. 그런데 브라우저가 보는 요청 주소가 /api/studies처럼 현재 프론트 서버와 같은 출처라면 교차 출처 요청이 아닙니다. 실제 백엔드 호출은 브라우저 밖에 있는 개발 서버가 대신 수행합니다.

프록시 방식의 장점은 다음과 같습니다.
  • 개발 환경에서 CORS 설정 때문에 막히지 않고 빠르게 화면 개발을 진행할 수 있습니다.
  • 프론트 코드의 API base URL을 /api처럼 단순하게 유지할 수 있습니다.
  • 로컬 개발 환경과 운영 환경의 URL 차이를 설정으로 흡수할 수 있습니다.
단점도 분명합니다.
  • 개발 서버 프록시는 보통 로컬 개발용입니다. 운영 CORS 정책을 대신 설계하지 않습니다.
  • 프록시 설정이 운영 배포 구조와 다르면 로컬에서는 되는데 운영에서는 깨질 수 있습니다.
  • 인증 쿠키, 도메인, SameSite, HTTPS 조건이 얽히면 프록시만으로 문제를 해결할 수 없습니다.

7-3. 운영에서는 리버스 프록시나 BFF가 같은 역할을 할 수 있다

운영 환경에서도 비슷한 구조를 만들 수 있습니다. 예를 들어 사용자는 https://www.example.com만 바라보고, 내부에서 Nginx나 API Gateway가 /api 요청을 백엔드로 전달하게 만들 수 있습니다.

text
브라우저
  -> https://www.example.com/api/studies
  -> Nginx 또는 API Gateway
  -> 내부 백엔드 서버

이렇게 하면 브라우저 입장에서는 같은 Origin으로 요청하는 것처럼 보입니다. 다만 이 구조는 배포 아키텍처와 관련이 있으므로 프론트엔드 혼자 결정할 수 없습니다. 백엔드, 인프라, 배포 환경과 함께 정해야 합니다.

7-4. CORS 디버깅 체크리스트

CORS 에러가 났을 때는 에러 메시지만 보고 추측하지 말고 순서대로 확인하는 편이 좋습니다.

text
1. 브라우저 Network 탭에서 실패한 요청을 연다.
2. Request Headers의 Origin을 확인한다.
3. Response Headers의 Access-Control-Allow-Origin을 확인한다.
4. OPTIONS 요청이 있는지 확인한다.
5. OPTIONS 응답에 Allow-Methods, Allow-Headers가 있는지 확인한다.
6. 쿠키 요청이면 credentials와 Allow-Credentials를 확인한다.
7. 서버 로그에는 성공인데 브라우저만 막는 상황인지 확인한다.

이 과정을 거치면 "백엔드가 죽었다"와 "브라우저가 응답을 막았다"를 구분할 수 있습니다. 이 구분이 되어야 프론트엔드와 백엔드가 서로 엉뚱한 곳을 고치지 않습니다.


8. API 호출 도구의 발전 흐름

이제 CORS까지 이해했으니, 프론트엔드 코드 안에서 API를 어떻게 호출하고 관리할지 볼 수 있습니다. 도구를 바로 비교하기보다, 어떤 불편함을 해결하기 위해 다음 도구가 등장했는지 흐름으로 보는 편이 좋습니다.

8-1. AJAX — 페이지 전체가 아니라 데이터만 바꾸기

예전 웹은 버튼을 누르거나 링크를 클릭하면 페이지 전체를 다시 받아왔습니다. AJAX가 등장하면서 페이지를 새로고침하지 않고 필요한 데이터만 받아와 화면의 일부만 바꾸는 방식이 가능해졌습니다.

text
AJAX 이전: 요청 -> 새 HTML 전체 다운로드 -> 페이지 전체 교체
AJAX 이후: 요청 -> JSON 데이터 다운로드 -> 필요한 UI만 갱신

이 변화 덕분에 Gmail, Google Maps처럼 페이지 전환 없이 부드럽게 동작하는 웹 서비스가 가능해졌습니다. 대신 비동기 요청이 많아지면서 콜백 지옥, 에러 처리, 공통 요청 설정 같은 새로운 문제가 생겼습니다.

8-2. 콜백에서 Promise, async/await으로

비동기 요청은 응답이 언제 올지 알 수 없습니다. 처음에는 콜백 함수로 응답을 처리했지만, 요청이 이어질수록 코드가 깊게 중첩됐습니다.

js
getUser(userId, function (user) {
  getOrders(user.id, function (orders) {
    getOrderDetail(orders[0].id, function (orderDetail) {
      // 계속 중첩됨
    });
  });
});

Promise와 async/await은 이 문제를 해결했습니다. 서버 통신 코드를 동기 코드처럼 읽을 수 있게 만들었고, 에러 처리도 try/catch로 모을 수 있게 됐습니다.

ts
async function loadUserOrderInfo(userId: number) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const orderDetail = await getOrderDetail(orders[0].id);
    return orderDetail;
  } catch (error) {
    console.error("요청 실패", error);
  }
}

8-3. Fetch API — 브라우저 내장 HTTP 클라이언트

fetch는 Promise 기반의 브라우저 내장 HTTP 클라이언트입니다. 별도 라이브러리 없이 사용할 수 있고, XMLHttpRequest보다 문법이 훨씬 직관적입니다.

ts
const response = await fetch("/api/users");
const data = await response.json();
하지만 fetch에도 실무에서 불편한 지점이 있습니다.
  • 404, 500 같은 HTTP 에러가 자동으로 catch로 가지 않습니다.
  • JSON 변환을 매번 response.json()으로 직접 해야 합니다.
  • timeout, 공통 헤더, 인터셉터 같은 기능을 직접 만들어야 합니다.
  • 요청 취소는 AbortController를 알아야 합니다.
ts
const response = await fetch("/api/users");

if (!response.ok) {
  throw new Error("HTTP error");
}

const data = await response.json();

8-4. Axios — 실무 편의 기능을 모은 HTTP 클라이언트

axios는 fetch의 부족한 부분을 보완하기 위해 많이 사용됩니다. 자동 JSON 변환, timeout, 에러 처리, 인스턴스, 인터셉터 같은 기능을 제공합니다. 브라우저와 Node.js에서 비슷한 방식으로 쓸 수 있다는 점도 장점입니다.

ts
const response = await axios.get("/api/users");
const data = response.data;

fetch와 달리 axios는 2xx가 아닌 HTTP 상태 코드를 에러로 처리합니다. 그래서 try/catch 안에서 성공과 실패를 나누기 쉽습니다.

ts
try {
  const response = await axios.get("/api/users");
  console.log(response.data);
} catch (error) {
  if (axios.isAxiosError(error) && error.response) {
    console.log(error.response.status);
    console.log(error.response.data);
  }
}

8-5. API Client — 반복되는 HTTP 설정을 한 곳으로 모으기

프로젝트가 커지면 모든 컴포넌트에서 fetch("https://api.example.com/...")를 직접 쓰는 방식은 금방 무너집니다. base URL, credentials, 공통 헤더, 에러 변환, timeout 같은 규칙이 여러 파일에 흩어지기 때문입니다.

ts
const apiClient = axios.create({
  baseURL: "/api",
  timeout: 10000,
  withCredentials: true,
});

API Client는 "우리 프로젝트에서 서버와 통신할 때 지킬 공통 규칙"을 담는 장소입니다. 개별 API 함수는 이 클라이언트를 사용해서 도메인별 요청만 표현하면 됩니다.

ts
export async function getStudies(params: GetStudiesParams) {
  const response = await apiClient.get<StudyListResponse>("/studies", {
    params,
  });

  return response.data;
}

8-6. 인터셉터 — 요청과 응답을 가로채는 공통 처리

인터셉터는 모든 요청이나 모든 응답 앞뒤에 공통 로직을 끼워 넣는 기능입니다. 실무에서는 토큰 자동 추가, 401 에러 처리, 공통 에러 변환에 많이 사용합니다.

ts
apiClient.interceptors.request.use((config) => {
  // 학습을 위한 단순 예시입니다.
  // localStorage에 토큰을 저장하면 XSS 공격에 노출될 수 있습니다.
  const token = localStorage.getItem("accessToken");

  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

인터셉터를 쓰면 모든 API 함수에서 토큰을 반복해서 꺼내 붙이지 않아도 됩니다. 공통 로직은 한 곳에 모이고, 개별 API 함수는 "무슨 요청을 보낼지"에 집중할 수 있습니다.

다만 위 코드는 인터셉터 개념을 설명하기 위한 단순 예시입니다. 실무에서는 access token을 localStorage에 오래 보관하는 방식을 기본값으로 두면 안 됩니다. localStorage는 JavaScript로 읽을 수 있기 때문에 XSS가 발생했을 때 토큰이 탈취될 수 있습니다.


9. 인증과 API — 토큰, 쿠키, CORS가 만나는 지점

9-1. Access Token과 Refresh Token

HTTP는 이전 요청을 기억하지 않기 때문에, 로그인 이후 요청마다 사용자를 증명할 정보가 필요합니다. 이때 access token과 refresh token을 함께 쓰는 구조가 자주 등장합니다.

text
로그인 성공
  -> Access Token 발급: 짧은 수명, API 요청에 사용
  -> Refresh Token 발급: 긴 수명, Access Token 재발급에 사용

백엔드와 맞춰야 할 약속은 구체적이어야 합니다. 토큰을 어느 헤더에 넣는지, Bearer를 붙이는지, 만료되면 어떤 상태 코드가 오는지, refresh API는 어떤 응답을 주는지 확인해야 합니다.

http
Authorization: Bearer {accessToken}

9-2. 401이 왔을 때 자동 갱신하기

401 응답이 왔을 때 인터셉터에서 토큰을 갱신하고 원래 요청을 다시 보내는 패턴도 자주 사용됩니다. 다만 동시에 여러 요청이 401을 받으면 refresh 요청이 중복될 수 있으므로, 실무에서는 중복 갱신을 막는 설계도 필요합니다.

text
요청 A, B, C가 동시에 401을 받음
  -> refresh 요청을 세 번 보내면 위험
  -> 하나의 refresh만 진행하고 나머지는 그 결과를 기다리게 설계

9-3. 쿠키 기반 인증과 CORS credentials

Refresh Token은 특히 더 조심해야 합니다. 브라우저 JavaScript가 직접 읽는 저장소에 두기보다, 서버가 내려주는 HttpOnly + Secure 쿠키로 보관하는 방식을 많이 사용합니다. 이 경우 프론트엔드는 토큰 문자열을 직접 다루지 않고, 브라우저가 쿠키를 요청에 함께 보내도록 둡니다.

대신 쿠키 기반 인증에서는 SameSite 설정, CSRF 대응, CORS credentials 설정을 백엔드와 함께 맞춰야 합니다. 앞에서 본 CORS credentials 규칙이 여기서 다시 등장합니다.

ts
await fetch("https://api.example.com/me", {
  credentials: "include",
});
http
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true
Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=None

즉 인증은 단순히 토큰을 저장하는 문제가 아닙니다. 브라우저 보안 정책, 쿠키 정책, CORS, 백엔드 세션/토큰 전략이 함께 맞아야 합니다.


  1. TanStack Query — HTTP 통신이 아니라 서버 상태 관리

어느 순간부터 프론트엔드 개발자들은 HTTP 요청 자체보다 서버 데이터 상태 관리에 더 많은 시간을 쓰고 있다는 걸 알게 됩니다. 로딩, 에러, 캐싱, 중복 요청 제거, 백그라운드 갱신, 낙관적 업데이트 같은 문제입니다.

tsx
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  axios
    .get("/api/users")
    .then((response) => setUsers(response.data))
    error  error

TanStack Query는 이런 반복을 줄여주는 서버 상태 관리 도구입니다. axios가 서버에 요청을 보내고 응답을 받아오는 역할이라면, TanStack Query는 받아온 데이터를 캐시에 저장하고, 언제 새로 가져올지, 어떤 컴포넌트에 전달할지를 관리합니다.

tsx
const { data, isLoading, error } = useQuery({
  queryKey: ["users"],
  queryFn: () => apiClient.get("/users").then((res) => res.data),
  staleTime: 5 * 60 * 1000,
});

핵심은 역할 구분입니다. axios나 fetch는 데이터를 가져오고, TanStack Query는 그 데이터를 서버 상태로 관리합니다.

10-1. Query와 Mutation

TanStack Query에서는 데이터를 읽는 작업과 쓰는 작업을 구분합니다. Query는 서버 데이터를 읽는 작업이고, Mutation은 생성, 수정, 삭제처럼 서버 데이터를 바꾸는 작업입니다.

구분QueryMutation
용도데이터 읽기데이터 생성/수정/삭제
주로 쓰는 HTTPGETPOST, PUT, PATCH, DELETE
실행 시점컴포넌트 마운트 시 자동mutate 호출 시 수동
캐싱자동 캐싱
tsx
const queryClient = useQueryClient();

const createStudy = useMutation({
  mutationFn: (newStudy) => studyApi.createStudy(newStudy),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["studies"] });
  },
});

새 스터디를 만든 뒤 스터디 목록 query를 무효화하면, TanStack Query가 목록을 다시 가져옵니다. 직접 setStudies로 상태를 여기저기 수정하지 않아도 서버 데이터와 화면을 다시 맞출 수 있습니다.

10-2. Query Key는 캐시의 주소다

Query Key는 TanStack Query 캐시에서 데이터를 구분하는 주소입니다. 같은 key를 쓰면 같은 서버 상태로 취급하고, key가 달라지면 다른 데이터로 취급합니다.

ts
["studies", "list", { status: "OPEN", page: 1 }][("studies", "detail", 123)];

Query Key를 대충 문자열로 흩어두면 나중에 invalidate가 어려워집니다. 그래서 기능 단위로 query key factory를 만들어두면 관리하기 좋습니다.

ts
export const studyQueries = {
  all: ["studies"] as const,
  lists: () => [...studyQueries.all, "list"] as const,
  list: (params: GetStudiesParams) =>
    [...studyQueries.lists(), params] as const,
  detail: (studyId: number) =>
    [...studyQueries.all, "detail", studyId] as const,
};

11. API 파일은 어디에 둬야 할까

지난 대면에서 UI, 상태, 서버 데이터, 비즈니스 규칙을 나눠서 봤습니다. API 관련 파일도 같은 기준으로 배치하면 됩니다. 핵심은 "서버와 통신하는 코드"와 "화면을 그리는 코드"를 섞지 않는 것입니다.

작은 프로젝트에서는 아래처럼 시작할 수 있습니다.
text
src/
  api/
    client.ts
    studies.ts
  pages/
    StudyListPage.tsx
  components/
    StudyCard.tsx

client.ts에는 base URL, credentials, 공통 헤더, 에러 변환 같은 HTTP 공통 정책을 둡니다. studies.ts에는 스터디 도메인의 API 함수들을 둡니다. 화면은 getStudies() 같은 함수를 호출할 뿐, URL 문자열과 HTTP 세부 설정을 직접 알지 않는 편이 좋습니다.

ts
// api/studies.ts
export async function getStudies(params: GetStudiesParams) {
  const response = await apiClient.get<StudyListResponse>("/studies", {
    params,
  });

  return response.data;
}

프로젝트가 커지면 기능 단위로 가까이 두는 방식도 좋습니다.

text
src/
  shared/
    api/
      httpClient.ts
      apiError.ts
  features/
    studies/
      api/
        studyApi.ts
        studyQueries.ts
      model/
        studyTypes.ts
        studyPolicy.ts
      ui/
        StudyCard.tsx
        StudyList.tsx

이 구조에서는 스터디 기능과 관련된 API, query key, 타입, 정책, UI가 한 기능 안에 모입니다. 대신 공통 HTTP 클라이언트는 여전히 바깥에 둡니다.

파일을 나누는 기준은 "기술 이름"이 아니라 "변경 이유"입니다. 서버 공통 정책이 바뀌면 shared/api/httpClient.ts가 바뀌고, 스터디 목록 응답이 바뀌면 features/studies/api/studyApi.ts가 바뀌고, 화면 배치가 바뀌면 UI 파일이 바뀌는 구조가 좋습니다.


12. 프론트엔드가 백엔드에게 잘 질문하는 법

"API 언제 돼요?"보다 좋은 질문은 구체적입니다. 좋은 질문은 화면 요구사항을 API 계약 언어로 바꿔 말합니다.

text
스터디 목록 화면에서 모집 중/마감 상태에 따라 버튼이 달라집니다.
응답에 status 필드를 OPEN/CLOSED 형태로 받을 수 있을까요?
빈 목록일 때는 items: []와 totalCount: 0으로 내려오는지 확인 부탁드립니다.

이렇게 질문하면 백엔드도 무엇을 맞춰야 하는지 명확해집니다. 좋은 프론트엔드 개발자는 화면 요구사항을 API 계약 언어로 바꿔 말할 수 있어야 합니다.

실제로는 이런 질문들이 자주 필요합니다.
  • 이 API는 로그인하지 않은 사용자도 호출할 수 있나요?
  • 실패할 때 code 값은 어떤 목록 중 하나인가요?
  • 빈 목록은 200과 빈 배열인가요, 아니면 404인가요?
  • 페이지 번호는 0부터 시작하나요, 1부터 시작하나요?
  • 정렬 기본값은 무엇인가요?
  • 검색어가 비어 있으면 전체 목록인가요, 빈 목록인가요?
  • 삭제 성공 시 200인가요, 204인가요?
  • 401과 403을 어떻게 구분하나요?
  • refresh token 만료 시 어떤 응답이 오나요?
  • CORS 허용 Origin에 로컬 개발 주소와 배포 주소가 모두 들어가 있나요?
  • 쿠키 인증이라면 credentials, SameSite, Secure, CSRF 정책은 어떻게 맞추나요?

질문이 구체적일수록 프론트엔드와 백엔드가 서로 덜 추측합니다. API 협업의 목표는 상대를 재촉하는 것이 아니라, 화면과 서버가 같은 계약을 바라보게 만드는 것입니다.


13. 오늘 정리

오늘의 흐름은 하나로 이어집니다. 비동기 문법은 서버 통신을 다루기 위해 필요하고, 서버 통신은 HTTP 요청과 응답으로 이루어지며, API는 그 요청과 응답을 팀의 계약으로 정리한 것입니다.

브라우저에서 API를 호출하면 CORS 같은 보안 정책도 함께 만나게 됩니다. Origin, SOP, Preflight를 이해하면 CORS 에러가 단순히 "서버가 이상하다"가 아니라 "브라우저가 이 응답을 JavaScript에 넘겨도 되는지 확인하고 있다"는 문제로 보입니다.

그다음 fetch, axios, API Client, 인터셉터, TanStack Query는 모두 같은 목표로 이어집니다. 서버와 통신하는 코드를 반복 없이, 안전하게, 프로젝트 구조 안에서 유지보수하기 쉽게 만들기 위한 도구입니다.

7주차에서 배운 책임 분리를 떠올리면 오늘 내용도 같은 흐름입니다. UI는 UI답게, API 함수는 API답게, 서버 상태 관리는 서버 상태 관리답게 두어야 프로젝트가 커져도 변경 범위를 좁힐 수 있습니다.


14. 다음 주 안내

다음 대면에서는 Next.js와 웹 렌더링을 다룹니다. 오늘 배운 API 통신은 "브라우저에서 데이터를 가져와 화면을 바꾸는 방식"에 가깝습니다. 다음 주에는 한 걸음 더 나아가, 어떤 데이터는 서버에서 미리 가져오고, 어떤 화면은 정적으로 만들고, 어떤 부분은 브라우저에서 하이드레이션하는지 살펴봅니다.

즉 오늘의 질문이 "프론트엔드는 서버와 어떻게 대화하는가"였다면, 다음 주의 질문은 "그 대화를 브라우저에서 할 것인가, 서버에서 미리 할 것인가"입니다. 이 연결을 기억하면 Next.js의 서버 렌더링, 정적 생성, 하이드레이션이 훨씬 자연스럽게 보입니다.

포스트 목록

/study/clab-26-1/in-person
파일 11개, 폴더 0개
프론트엔드 스터디 대면 0주차: 프론트엔드 개발자란? 그리고 우리가 배울 것들프론트엔드 스터디 대면 1주차: HTML 마크업과 폼, 그리고 CSS의 시작프론트엔드 스터디 대면 2주차: 폼(Form), CSS 선택자, 그리고 박스 모델프론트엔드 스터디 대면 3주차: 박스 모델 실전, Position과 Flexbox프론트엔드 스터디 대면 6주차(1): HTML/CSS/JS 리마인드와 브라우저 렌더링프론트엔드 스터디 대면 6주차(2): AI 시대의 개발 방식과 프론트엔드 개발자의 위치프론트엔드 스터디 대면 7주차: React 입문과 프로젝트 구조프론트엔드 스터디 대면 8주차: API와 통신 — 프론트엔드와 백엔드의 계약프론트엔드 스터디 대면 9주차: Next.js 렌더링 진화와 웹 퍼포먼스프론트엔드 스터디 대면 10주차: 팀 협업 — Git, PR, 컨벤션, 리뷰 문화프론트엔드 스터디 대면 11주차: 배포와 운영 — localhost 밖의 세계
DELETE삭제댓글 삭제
401인증 필요로그인 화면 이동 또는 토큰 갱신
403권한 없음접근 불가 안내
404대상 없음not found 화면 또는 빈 상태
409충돌이미 신청됨, 이미 사용 중인 이름 등
500서버 오류잠시 후 다시 시도 안내
.
catch
(
(
)
=>
setError
(
)
)
.finally(() => setLoading(false));
}, []);
직접 무효화/갱신 필요