이번 주 비대면에서는 클로저, Promise, async/await를 배웠습니다. 이 문법들이 실제 웹에서 가장 많이 쓰이는 곳은 서버와 통신할 때입니다. 버튼을 눌렀을 때 서버에 요청을 보내고, 응답을 기다리고, 성공과 실패에 따라 화면을 바꾸는 모든 과정이 비동기 위에 있습니다.
오늘은 fetch 문법을 다시 외우는 시간이 아닙니다. API를
프론트엔드와 백엔드 사이의 계약으로 바라보고, 그 계약을
프로젝트 안에서 어디에 배치해야 유지보수하기 쉬운지까지 연결해보겠습니다.
프론트엔드 입장에서 API는 "서버에서 데이터를 받아오는 주소"처럼 보입니다. 하지만 팀 관점에서 API는 훨씬 중요합니다. 프론트엔드와 백엔드가 서로 기대하는 데이터의 모양, 에러의 형태, 권한 규칙, 페이지네이션 방식이 모두 API에 담깁니다.
GET /api/studies?page=1&size=20
이 요청 하나에도 많은 약속이 들어있습니다. page는 0부터 시작하는지 1부터 시작하는지, size 최대값은 얼마인지, 로그인이 필요할지, 실패하면 어떤 에러가 올지, 빈 목록이면 어떤 응답이 올지 정해야 합니다.
그래서 API를 잘 다룬다는 것은 단순히 요청을 보낼 수 있다는 뜻이 아닙니다. 화면 요구사항을 요청과 응답의 언어로 바꾸고, 백엔드와 합의한 내용을 코드에 안정적으로 반영할 수 있다는 뜻입니다.
{
"items": [
{
"id": 1,
"title": "프론트엔드 스터디",
"status": "OPEN"
}
],
"page": 1,
"totalCount": 42
}
프론트엔드는 이 응답을 화면으로 바꿉니다. 그래서 백엔드가 내려주는 필드 이름과
화면에서 필요한 정보가 맞아야 합니다. status: "OPEN"을 "모집 중"으로
보여줄지, 버튼 노출 조건으로 쓸지 같은 판단도 필요합니다.
성공 응답보다 더 중요한 것이 에러 응답입니다. 실제 서비스에서는 실패가 자주 일어납니다. 네트워크가 끊기고, 권한이 없고, 입력값이 틀리고, 서버가 죽습니다.
{
"code": "STUDY_ALREADY_CLOSED",
"message": "이미 마감된 스터디입니다."
}
에러가 이렇게 내려오면 프론트엔드는 사용자에게 적절한 메시지를 보여줄 수 있습니다. 반대로 모든 에러가 그냥 500으로만 내려오면, 화면에서는 "알 수 없는 오류" 말고 할 수 있는 게 없습니다.
API 협업에서 자주 빠지는 것이 빈 상태입니다. 데이터가 없을 때 빈 배열을 줄지, null을 줄지, 404로 볼지, 정상 응답으로 볼지 합의해야 합니다.
이들은 모두 화면이 다릅니다. 그래서 API 응답과 UI 상태를 함께 설계해야 합니다.
목록 API에서는 페이지네이션이 거의 항상 등장합니다. 이때 offset 방식인지 cursor 방식인지, 정렬 기준은 무엇인지, 다음 페이지가 있는지 어떻게 알려줄지 정해야 합니다.
작은 프로젝트에서는 별것 아닌 것처럼 보여도, 무한 스크롤이나 검색 필터가 붙으면 이 결정이 사용자 경험과 성능을 크게 좌우합니다.
CORS는 처음 보면 백엔드 에러처럼 보이지만, 사실 브라우저의 보안 정책과 관련이 있습니다. 브라우저는 기본적으로 같은 출처의 리소스만 자유롭게 읽을 수 있게 하는 SOP를 가지고 있고, CORS는 다른 출처의 리소스를 안전하게 공유하기 위한 예외 규칙입니다.
여기서 출처(origin)는 보통 프로토콜, 호스트, 포트의 조합으로 봅니다.
http://localhost:5173
http://localhost:8080
두 주소는 둘 다 localhost지만 포트가 다릅니다. 브라우저 입장에서는 다른 출처입니다. 그래서 프론트 개발 서버에서 백엔드 개발 서버로 요청을 보낼 때 CORS 에러를 자주 만나게 됩니다.
CORS의 핵심은 요청을 막는 주체가 서버가 아니라 브라우저라는 점입니다. 서버는 응답을 보냈더라도, 브라우저가 응답 헤더를 보고 "이 출처에 공개해도 된다"는 허가를 확인하지 못하면 JavaScript 코드에 응답을 넘기지 않습니다.
브라우저는 다른 출처로 요청을 보낼 때 요청 헤더에 Origin을 담습니다. 서버는 응답 헤더에 어떤 출처를 허용할지 적어 보냅니다. 브라우저는 이 둘을 비교합니다.
Origin: http://localhost:5173
Access-Control-Allow-Origin: http://localhost:5173
값이 맞으면 브라우저는 응답을 JavaScript에 넘깁니다. 맞지 않으면 네트워크 요청 자체가 성공했더라도 프론트엔드 코드에서는 응답을 사용할 수 없습니다.
실제 요청 전에 브라우저가 OPTIONS 요청을 먼저 보내는 경우가 있습니다. 이것을 Preflight라고 부릅니다. "이 메서드와 이 헤더로 요청해도 되는지"를 미리 물어보는 절차입니다.
OPTIONS /api/studies
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
서버가 허용하는 origin, method, headers를 응답하면 브라우저가 그제야 실제
요청을 보냅니다. 우리가 흔히 쓰는 Content-Type: application/json이나
Authorization 헤더는 Preflight를 유발하는 경우가 많습니다.
쿠키나 인증 헤더처럼 자격 증명이 포함되는 요청은 더 엄격합니다. 프론트에서는
credentials: "include" 또는 axios의 withCredentials: true 같은 설정이
필요하고, 서버도 Access-Control-Allow-Credentials: true를 내려줘야 합니다.
이때 Access-Control-Allow-Origin: *처럼 모든 출처를 허용하는 방식은 사용할
수 없습니다.
가장 정석적인 해결은 백엔드가 실제로 허용할 프론트엔드 출처를 응답 헤더에 명시하는 것입니다.
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을 열기보다, 실제 서비스 도메인과 개발 도메인을 구분해서 관리해야 합니다. CORS는 귀찮은 에러가 아니라 "누가 이 응답을 읽을 수 있는가"를 정하는 보안 경계입니다.
개발 환경에서는 프론트 개발 서버가 백엔드로 요청을 대신 전달하는 프록시를 자주 씁니다. 브라우저는 같은 출처인 프론트 개발 서버에 요청한다고 느끼고, 프론트 개발 서버가 뒤에서 백엔드 서버로 요청을 넘깁니다.
// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});
// 브라우저에서는 같은 출처로 요청하는 것처럼 보인다.
await fetch("/api/studies");
원리는 단순합니다. CORS는 브라우저가 다른 출처의 응답을 JavaScript에 넘길지
판단하는 규칙입니다. 그런데 브라우저가 보는 요청 주소가 /api/studies처럼
현재 프론트 서버와 같은 출처라면, 브라우저 입장에서는 교차 출처 요청이
아닙니다. 실제 백엔드 호출은 브라우저 밖에 있는 개발 서버가 대신 수행합니다.
/api처럼 단순하게 유지할 수 있습니다.따라서 프록시는 "CORS를 없애는 마법"이 아니라 브라우저가 보는 요청 경로를 같은 출처처럼 만들어주는 개발/배포 구조 입니다. 운영에서는 Nginx, Next.js Route Handler, BFF, API Gateway 같은 계층이 비슷한 역할을 맡을 수 있습니다.
API 요청 도구를 고를 때 바로 axios, fetch, TanStack Query부터 비교하면 흐름이 잘 보이지 않습니다. 먼저 웹 통신이 어떤 문제를 해결하며 발전해왔는지 봐야 합니다.
HTTP는 클라이언트가 요청하면 서버가 응답하는 규약입니다. 중요한 특징은 무상태성입니다. 서버는 기본적으로 이전 요청을 기억하지 않습니다. 그래서 로그인 이후의 요청에는 "내가 누구인지"를 알려주는 토큰이나 쿠키 같은 인증 정보가 필요합니다.
GET /api/users/123 HTTP/1.1
Host: example.com
Authorization: Bearer access-token
API 협업에서 메서드도 약속입니다. 보통 GET은 조회, POST는 생성, PUT/PATCH는 수정, DELETE는 삭제에 사용합니다. 이 의미가 맞아야 프론트엔드와 백엔드가 같은 그림을 보고 개발할 수 있습니다.
예전 웹은 버튼을 누르거나 링크를 클릭하면 페이지 전체를 다시 받아왔습니다. AJAX가 등장하면서 페이지를 새로고침하지 않고 필요한 데이터만 받아와 화면의 일부만 바꾸는 방식이 가능해졌습니다.
AJAX 이전: 요청 → 새 HTML 전체 다운로드 → 페이지 전체 교체
AJAX 이후: 요청 → JSON 데이터 다운로드 → 필요한 UI만 갱신
이 변화 덕분에 Gmail, Google Maps처럼 페이지 전환 없이 부드럽게 동작하는 웹 서비스가 가능해졌습니다. 대신 비동기 요청이 많아지면서 콜백 지옥, 에러 처리, 공통 요청 설정 같은 새로운 문제가 생겼습니다.
비동기 요청은 응답이 언제 올지 알 수 없습니다. 처음에는 콜백 함수로 응답을 처리했지만, 요청이 이어질수록 코드가 깊게 중첩됐습니다.
getUser(userId, function (user) {
getOrders(user.id, function (orders) {
getOrderDetail(orders[0].id, function (orderDetail) {
// 계속 중첩됨
});
});
});
Promise와 async/await은 이 문제를 해결했습니다. 서버 통신 코드를 동기 코드처럼
읽을 수 있게 만들었고, 에러 처리도 try/catch로 모을 수 있게 됐습니다.
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);
}
}
fetch는 Promise 기반의 브라우저 내장 HTTP 클라이언트입니다. 별도 라이브러리 없이 사용할 수 있고, XMLHttpRequest보다 문법이 훨씬 직관적입니다.
const response = await fetch("/api/users");
const data = await response.json();
catch로 가지 않습니다.response.json()으로 직접 해야 합니다.AbortController를 알아야 합니다.const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("HTTP error");
}
const data = await response.json();
axios는 fetch의 부족한 부분을 보완하기 위해 많이 사용됩니다. 자동 JSON 변환, timeout, 에러 처리, 인스턴스, 인터셉터 같은 기능을 제공합니다. 브라우저와 Node.js에서 비슷한 방식으로 쓸 수 있다는 점도 장점입니다.
const response = await axios.get("/api/users");
const data = response.data;
fetch와 달리 axios는 2xx가 아닌 HTTP 상태 코드를 에러로 처리합니다. 그래서
try/catch 안에서 성공과 실패를 나누기 쉽습니다.
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);
}
}
인터셉터는 모든 요청이나 모든 응답 앞뒤에 공통 로직을 끼워 넣는 기능입니다. 실무에서는 토큰 자동 추가, 401 에러 처리, 공통 에러 변환에 많이 사용합니다.
const apiClient = axios.create({
baseURL: "/api",
timeout: 10000,
withCredentials: true,
});
apiClient.interceptors.request.use((config) => {
// 학습을 위한 단순 예시입니다.
// localStorage에 토큰을 저장하면 XSS 공격에 노출될 수 있습니다.
const token = localStorage.getItem("accessToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
인터셉터를 쓰면 모든 API 함수에서 토큰을 반복해서 꺼내 붙이지 않아도 됩니다. 공통 로직은 한 곳에 모이고, 개별 API 함수는 "무슨 요청을 보낼지"에 집중할 수 있습니다.
다만 위 코드는 인터셉터 개념을 설명하기 위한 단순 예시입니다. 실무에서는 access token을 localStorage에 오래 보관하는 방식을 기본값으로 두면 안 됩니다. localStorage는 JavaScript로 읽을 수 있기 때문에 XSS가 발생했을 때 토큰이 탈취될 수 있습니다. 보안이 중요한 서비스라면 서버가 설정하는 HttpOnly, Secure, SameSite 쿠키 기반 인증이나 그에 준하는 안전한 저장 전략을 함께 검토해야 합니다.
HTTP는 이전 요청을 기억하지 않기 때문에, 로그인 이후 요청마다 사용자를 증명할 정보가 필요합니다. 이때 access token과 refresh token을 함께 쓰는 구조가 자주 등장합니다.
로그인 성공
→ Access Token 발급: 짧은 수명, API 요청에 사용
→ Refresh Token 발급: 긴 수명, Access Token 재발급에 사용
백엔드와 맞춰야 할 약속은 구체적이어야 합니다. 토큰을 어느 헤더에 넣는지,
Bearer를 붙이는지, 만료되면 어떤 상태 코드가 오는지, refresh API는 어떤 응답을
주는지 확인해야 합니다.
Authorization: Bearer {accessToken}
401 응답이 왔을 때 인터셉터에서 토큰을 갱신하고 원래 요청을 다시 보내는 패턴도 자주 사용됩니다. 다만 동시에 여러 요청이 401을 받으면 refresh 요청이 중복될 수 있으므로, 실무에서는 중복 갱신을 막는 설계도 필요합니다.
Refresh Token은 특히 더 조심해야 합니다. 브라우저 JavaScript가 직접 읽는 저장소에 두기보다, 서버가 내려주는 HttpOnly + Secure 쿠키로 보관하는 방식을 많이 사용합니다. 이 경우 프론트엔드는 토큰 문자열을 직접 다루지 않고, 브라우저가 쿠키를 요청에 함께 보내도록 둡니다. 대신 쿠키 기반 인증에서는 SameSite 설정, CSRF 대응, CORS credentials 설정을 백엔드와 함께 맞춰야 합니다.
어느 순간부터 프론트엔드 개발자들은 HTTP 요청 자체보다 서버 데이터 상태 관리에 더 많은 시간을 쓰고 있다는 걸 알게 됐습니다. 로딩, 에러, 캐싱, 중복 요청 제거, 백그라운드 갱신, 낙관적 업데이트 같은 문제입니다.
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는 받아온 데이터를 캐시에 저장하고, 언제 새로 가져올지, 어떤 컴포넌트에 전달할지를 관리합니다.
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: () => apiClient.get("/users").then((res) => res.data),
staleTime: 5 * 60 * 1000,
});
핵심은 역할 구분입니다. axios는 택배 기사처럼 데이터를 가져오고, TanStack Query는 창고 관리자처럼 그 데이터를 보관하고 갱신합니다.
TanStack Query에서는 데이터를 읽는 작업과 쓰는 작업을 구분합니다. Query는 서버 데이터를 읽는 작업이고, Mutation은 생성, 수정, 삭제처럼 서버 데이터를 바꾸는 작업입니다.
| 구분 | Query | Mutation |
|---|---|---|
| 용도 | 데이터 읽기 | 데이터 생성/수정/삭제 |
| 주로 쓰는 HTTP | GET | POST, PUT, PATCH, DELETE |
| 실행 시점 | 컴포넌트 마운트 시 자동 | mutate 호출 시 수동 |
| 캐싱 | 자동 캐싱 |
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: (newUser) => apiClient.post("/users", newUser),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
새 사용자를 만든 뒤 사용자 목록 query를 무효화하면, TanStack Query가 목록을 다시
가져옵니다. 직접 setUsers로 상태를 여기저기 수정하지 않아도 서버 데이터와
화면을 다시 맞출 수 있습니다.
지난 대면에서 UI, 상태, 서버 데이터, 비즈니스 규칙을 나눠서 봤습니다. API 관련 파일도 같은 기준으로 배치하면 됩니다. 핵심은 "서버와 통신하는 코드"와 "화면을 그리는 코드"를 섞지 않는 것입니다.
src/
api/
client.ts
studies.ts
pages/
StudyListPage.tsx
components/
StudyCard.tsx
client.ts에는 base URL, credentials, 공통 헤더, 에러 변환 같은 HTTP 공통
정책을 둡니다. studies.ts에는 스터디 도메인의 API 함수들을 둡니다. 화면은
getStudies() 같은 함수를 호출할 뿐, URL 문자열과 HTTP 세부 설정을 직접 알지
않는 편이 좋습니다.
// api/studies.ts
export async function getStudies(params: GetStudiesParams) {
const response = await api.get("studies", { searchParams: params }).json();
return parseStudiesResponse(response);
}
src/
features/
studies/
api/
studyApi.ts
studyQueries.ts
model/
studyTypes.ts
studyPolicy.ts
ui/
StudyCard.tsx
StudyList.tsx
pages/
StudyListPage.tsx
이 구조에서는 스터디 기능과 관련된 API, query key, 타입, 정책, UI가 한 기능 안에 모입니다. 대신 공통 HTTP 클라이언트는 여전히 바깥에 둡니다.
src/
shared/
api/
httpClient.ts
apiError.ts
features/
studies/
api/
studyApi.ts
studyQueries.ts
studyApi.ts는 실제 HTTP 요청 함수, studyQueries.ts는 TanStack Query의 query
key와 query option을 담는 식으로 나누면 역할이 선명해집니다.
// features/studies/api/studyQueries.ts
export const studyQueries = {
all: ["studies"] as const,
list: (params: GetStudiesParams) => ({
queryKey: [...studyQueries.all, "list", params] as const,
queryFn: () => studyApi.getStudies(params),
}),
};
이렇게 해두면 화면 컴포넌트는 "스터디 목록을 가져온다"는 의도만 드러내고, HTTP 세부사항과 query key 규칙은 API 계층에 남습니다.
스터디 목록 화면에서 모집 중/마감 상태에 따라 버튼이 달라집니다.
응답에 status 필드를 OPEN/CLOSED 형태로 받을 수 있을까요?
빈 목록일 때는 items: []와 totalCount: 0으로 내려오는지 확인 부탁드립니다.
이렇게 질문하면 백엔드도 무엇을 맞춰야 하는지 명확해집니다. 좋은 프론트엔드 개발자는 화면 요구사항을 API 계약 언어로 바꿔 말할 수 있어야 합니다.
API는 데이터를 가져오는 기술이면서 동시에 팀 사이의 약속입니다. CORS는 그 약속이 브라우저 보안 규칙과 만나는 지점이고, HTTP 클라이언트와 TanStack Query는 API 코드를 프로젝트 구조 안에 안정적으로 배치하기 위한 도구입니다.
7주차에서 배운 책임 분리를 떠올리면 오늘 내용도 같은 흐름입니다. UI는 UI답게, API 함수는 API답게, 서버 상태 관리는 서버 상태 관리답게 두어야 프로젝트가 커져도 변경 범위를 좁힐 수 있습니다.
다음 대면에서는 Next.js와 웹 렌더링을 다룹니다. React로 화면을 만드는 것에서 한 걸음 더 나아가, 브라우저 렌더링, 서버 렌더링, 정적 생성, 하이드레이션이 왜 등장했는지 큰 흐름을 봅니다.
| 직접 무효화/갱신 필요 |