"API에서 데이터가 안 와요."
이 말은 거의 항상 틀린 문장이에요. 데이터는 왔는데 모양이 다르거나, 에러가 왔는데 성공처럼 처리됐거나, 아예 요청이 다른 곳으로 갔거나. 그래서 저는 코드부터 열지 않고 요청이 나갔나 → 상태 코드 → Content-Type → 본문 구조 → 기대값과 비교 → 그다음에 코드 순서로 봐요. 바깥(네트워크)에서 안쪽(코드)으로 좁혀 들어가는 거예요.
몇 년 동안 굳은 습관을, 단계마다 실제로 치는 명령과 자주 마주치는 에러 메시지까지 붙여서 정리했어요. 1~4단계는 코드 한 줄 고치지 않고 브라우저 개발자 도구와 curl만으로 확인할 수 있어서, 이 순서만 지켜도 엉뚱한 곳을 고치느라 쓰는 시간이 확 줄어요.
핵심 요약
코드를 의심하기 전에 Network 탭에서 요청이 실제로 나갔는지, 상태 코드와 Content-Type이 무엇인지부터 확인하세요.
200 OK여도 본문 안에 에러 코드가 들어 있는 API가 많으니 본문을 반드시 펼쳐 보세요.
응답 구조를 기대값과 나란히 놓고 키 이름, null, 배열/객체, 문자열/숫자 타입을 비교하면 원인 대부분이 여기서 잡혀요.
curl -i, Copy as cURL, jq 세 가지만 익혀 두면 브라우저 밖에서 같은 요청을 재현할 수 있어요.
1단계: 요청이 정말 나갔나 (Network 탭, CORS)
브라우저라면 개발자 도구 Network 탭을 열고 해당 요청이 목록에 있는지부터 봐요. 생각보다 자주 요청 자체가 안 나간 경우가 있어요. 조건문에 막혔거나, 이벤트 핸들러가 안 붙었거나, 이전 요청의 에러에서 함수가 조용히 종료됐거나요. 목록이 비어 있다면 API가 아니라 호출하는 쪽 코드 문제예요.
요청이 있다면 세 가지를 확인해요.
- URL: 환경 변수 때문에 개발 서버 대신 운영 서버로, 혹은
undefined/api/users같은 경로로 가고 있지 않은지 - 메서드: GET이어야 할 게 POST로, 혹은 그 반대로 가고 있지 않은지
- 요청 본문과 헤더:
Content-Type: application/json없이 JSON 문자열을 보내서 서버가 본문을 못 읽는 경우가 흔해요
목록에 빨간 줄로 (failed) 나 CORS error 가 보이면 콘솔에 이런 메시지가 같이 찍혀 있을 거예요.
Access to fetch at 'https://api.example.com/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
CORS 에러는 서버 응답 헤더 문제라서 프런트 코드로는 못 고쳐요. 서버가 해당 origin을 허용하도록 설정하거나, 개발 중엔 개발 서버 프록시를 쓰는 게 정석이에요. 중요한 건 이때 JS에서 받는 에러는 TypeError: Failed to fetch(Node에선 fetch failed) 하나뿐이라는 점이에요. 진짜 원인은 콘솔과 Network 탭에만 남으니, catch 블록의 메시지만 보고 판단하면 안 돼요.
2단계: 상태 코드 — 200인데 실패인 경우까지
상태 코드는 서버가 "내가 이 요청을 어떻게 처리했는지" 한 숫자로 요약한 거예요. 이걸 안 보고 본문부터 파싱하면 엉뚱한 에러 메시지를 쫓게 돼요.
| 코드 | 의미 | 먼저 볼 것 |
|---|---|---|
| 200 | 성공 | 본문 안의 success: false, code 필드 |
| 204 | 성공, 본문 없음 | res.json() 호출하면 파싱 에러 |
| 301·302·307 | 리다이렉트 | 로그인 페이지로 튕겼는지, http→https |
| 304 | 캐시 사용 | 서버 데이터가 바뀌었는데 화면이 그대로일 때 |
| 400 | 요청 형식 오류 | 요청 본문, 필수 파라미터, 날짜 형식 |
| 401 | 인증 없음·만료 | 토큰 유무, 만료 시각 |
| 403 | 권한 없음 | 계정 권한, CORS 사전 요청 거절 |
| 404 | 경로 없음 | URL 오타, 버전 경로(/v1), 끝 슬래시 |
| 409·422 | 비즈니스 규칙 위반 | 응답 본문의 에러 메시지 |
| 429 | 요청 과다 | Retry-After 헤더 |
| 500 | 서버 내부 오류 | 서버 로그 (클라이언트에서 할 일 거의 없음) |
| 502·503·504 | 게이트웨이·과부하·타임아웃 | 서버가 살아 있는지, 응답 시간 |
특히 조심할 게 200 OK인데 실패인 API예요. 국내 레거시 API나 일부 오픈 API는 모든 응답을 200으로 주고 본문에 {"resultCode": "99", "resultMsg": "SERVICE KEY IS NOT REGISTERED"} 같은 걸 담아요. res.ok 만 보고 성공 처리하면 화면엔 빈 목록만 뜨고 에러는 어디에도 안 남아요. API 문서에서 실패 응답 형식을 꼭 확인하세요.
401이 뜬다면 토큰이 만료된 경우가 많은데, 토큰 안의 exp 값을 JWT 디코더로 바로 확인할 수 있어요. 액세스 토큰 만료 처리 흐름은 액세스·리프레시 토큰 만료 설계 글에 정리해 뒀어요.
3단계: Content-Type — JSON이라고 믿기 전에
응답 헤더의 Content-Type 이 application/json 인지 봐요. text/html 이라면 에러 페이지가 온 거고, 그걸 JSON으로 파싱하려다 Unexpected token '<' 에러가 나는 거예요. 프록시나 로드밸런서가 내려주는 502 페이지, 개발 서버가 모든 경로에 돌려주는 index.html 이 대표적인 범인이에요.
curl로 헤더만 빠르게 보는 법은 이래요.
# -i: 응답 헤더 + 본문, -s: 진행 표시 끄기
curl -si https://api.github.com/zen | head -5
# HTTP/2 200
# content-type: text/plain;charset=utf-8
# ...
charset 도 같이 보세요. 오래된 서버가 charset=euc-kr 로 내려주는데 클라이언트가 UTF-8로 읽으면 한글이 ��� 로 깨져요. 이건 JSON 문법 문제가 아니라 인코딩 문제예요. 파싱 에러 메시지별 원인은 JSON 파싱 에러 원인 총정리에 따로 모아 뒀어요.
4단계: 응답 본문을 펼쳐서 구조 확인
여기까지 통과했다면 이제 본문을 봐요. 한 줄로 압축된 JSON을 눈으로 따라가는 건 고역이라, 저는 JSON 포맷터에 붙여 넣고 들여쓰기로 펼쳐서 봐요. 터미널에서는 jq가 최고예요.
# 전체를 예쁘게
curl -s https://api.example.com/users | jq .
# 필요한 필드와 그 타입만 뽑기
echo '{"data":{"items":[{"id":"1","name":"a"},{"id":2,"name":null}]}}' \
| jq -c '.data.items[] | {id, t: (.id|type), name}'
# {"id":"1","t":"string","name":"a"}
# {"id":2,"t":"number","name":null}
이 예시처럼 같은 배열 안에서 id 가 어떤 건 문자열, 어떤 건 숫자로 오는 API가 실제로 있어요. === 로 비교하는 코드라면 절반만 매칭되는 이상한 버그가 생기죠. 펼쳐 볼 때 확인하는 건 이 네 가지예요.
- 최상위 래퍼: 배열이 바로 오나,
data·result·items같은 키 안에 감싸져 오나 - 빈 값의 표현: 빈 배열
[],null, 키 자체가 없음 — 셋 다 다르게 처리해야 해요 - 숫자 형식:
1000인지"1,000"인지, 금액이 문자열로 오는지 - 날짜 형식: ISO 8601 문자열인지, 초·밀리초 타임스탬프인지
날짜가 숫자로 온다면 자릿수부터 보세요. 10자리면 초, 13자리면 밀리초예요. 헷갈리면 타임스탬프 변환기에 넣어 보면 바로 알 수 있어요.
5단계: 기대값과 나란히 놓고 비교하기
API 문서의 예시 응답이나, 잘 동작하던 시점의 응답을 옆에 놓고 비교해요. 차이는 대부분 이런 데서 나와요.
- 키 이름이
items에서item으로,userId에서user_id로 바뀜 - 배열이어야 할 게 결과가 1건일 때만 객체로 옴
- 페이지네이션 응답이
{list, total}구조로 바뀜
손으로 비교하기 귀찮을 때 쓰는 작은 함수가 있어요. 두 객체의 "모양(경로별 타입)"만 뽑아서 다른 곳을 찍어 줘요.
function shape(v, path = '$', out = {}) {
const t = v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v;
out[path] = t;
if (t === 'array' && v.length) shape(v[0], `${path}[0]`, out);
if (t === 'object') for (const k of Object.keys(v)) shape(v[k], `${path}.${k}`, out);
return out;
}
function diffShape(expected, actual) {
const a = shape(expected), b = shape(actual);
for (const p of new Set([...Object.keys(a), ...Object.keys(b)])) {
if (a[p] !== b[p]) console.log(`${p}: 기대 ${a[p] ?? '(없음)'} / 실제 ${b[p] ?? '(없음)'}`);
}
}
diffShape(
{ data: { items: [{ id: 1, price: 1000 }] } },
{ data: { item: [{ id: '1', price: '1,000' }] } }
);
// $.data.items: 기대 array / 실제 (없음)
// $.data.item: 기대 (없음) / 실제 array
// $.data.item[0].id: 기대 (없음) / 실제 string
// ...
이 단계에서 원인이 잡히는 경우가 체감상 제일 많아요. 프런트에서 보는 대표 증상이 바로 이 에러예요.
TypeError: Cannot read properties of undefined (reading 'map')
TypeError: Cannot read properties of null (reading 'map')
undefined 면 키 이름이 다르거나 래퍼 한 단계를 빼먹은 거고, null 이면 서버가 "데이터 없음"을 빈 배열 대신 null 로 준 거예요. 옵셔널 체이닝(data?.items ?? [])으로 일단 막을 수는 있지만, 그 전에 왜 그 값이 왔는지 확인해야 진짜 버그를 덮지 않아요.
6단계: 그다음에 코드 — 재현 가능한 기록 남기기
여기까지 와서야 파싱 로직, 상태 관리, 렌더링 코드를 봐요. 앞 단계에서 응답이 정상이라는 게 확인됐으니, 이제 문제는 확실히 우리 코드 안에 있어요. 범위가 좁혀진 상태라 고치는 것도 빨라요.
그리고 꼭 하는 게 재현 가능한 기록이에요. Network 탭에서 요청을 우클릭하고 Copy → Copy as cURL 을 누르면 헤더·쿠키·본문까지 포함한 curl 명령이 복사돼요. 이걸 이슈에 붙이면 다른 사람이 터미널에서 똑같은 요청을 재현할 수 있어요. 토큰과 쿠키는 붙이기 전에 꼭 지우세요.
느린 응답이 문제라면 curl의 -w 옵션으로 구간별 시간을 볼 수 있어요.
curl -s -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} \
tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://example.com
# dns=0.003 tcp=0.122 tls=0.267 ttfb=0.395 total=0.396
값은 요청 시작부터의 누적 시각이라, 앞 값을 빼면 구간별 시간이 나와요. 실제로 세 곳을 7번씩 재서 중앙값을 구간별로 나눠 보면 이래요.
같은 서버라도 TLS·연결 시간이 서버 처리 시간만큼 걸릴 수 있다
표로 보기
| 구분 | DNS+TCP 연결 | TLS 핸드셰이크 | 서버 처리 대기 | 본문 다운로드 |
|---|---|---|---|---|
| example.com | 121 | 134 | 128 | 0 |
| api.github.com | 10 | 11 | 10 | 0 |
| google.com | 40 | 44 | 103 | 72 |
ttfb - tls(서버 처리 대기)가 길면 서버 쪽 문제, 연결·TLS 구간이 길면 거리나 네트워크 문제예요. 위 결과에서 example.com은 서버가 해외에 있어 왕복 지연이 구간마다 쌓인 거고, api.github.com은 가까운 엣지에서 응답해서 전체가 30ms 남짓이었어요. "API가 느려요"라는 말도 이렇게 쪼개 보면 누가 고쳐야 할지 분명해져요.
자주 하는 실수 체크리스트
- 상태 코드를 안 보고
res.json()부터 호출 - catch 블록에서 에러를 삼키고 빈 배열로 대체 → 에러가 어디에도 안 남음
- 응답을
console.log(obj)로 찍고 나중에 펼쳐 봄 → 브라우저 콘솔은 펼치는 시점 값을 보여줘서 이후 변경이 반영돼 보일 수 있어요.JSON.stringify(obj)나structuredClone으로 스냅샷을 찍으세요 - 캐시된 응답(304, 서비스 워커)을 보고 서버가 안 바뀌었다고 판단 → Disable cache 체크
- 개발·운영 환경 변수 혼동 → 요청 URL의 호스트를 직접 확인
자주 묻는 질문
API 응답은 200인데 화면에 데이터가 안 나와요.
본문을 펼쳐서 실패 필드(success: false, resultCode 등)가 있는지 먼저 보세요. 그다음 데이터가 기대한 키 아래에 있는지, 빈 배열인지 null 인지 확인하면 대부분 원인이 나와요. 마지막으로 상태 관리나 렌더링 조건이 그 값을 걸러내고 있지 않은지 코드를 보면 돼요.
Failed to fetch 에러는 무슨 뜻인가요?
브라우저가 응답을 아예 받지 못했거나, CORS 정책 때문에 JS에 응답을 넘겨주지 않았다는 뜻이에요. 메시지만으로는 원인을 알 수 없으니 콘솔의 CORS 메시지와 Network 탭의 상태를 같이 보세요. 서버 다운, 인증서 오류, 광고 차단 확장 프로그램도 원인이 될 수 있어요.
Cannot read properties of undefined (reading 'map') 은 어떻게 고치나요?
배열이라고 생각한 값이 실제로는 undefined 라는 뜻이에요. 응답 구조를 펼쳐서 키 이름과 래퍼 단계를 확인하고, 로딩 전 초기값이 빈 배열인지도 보세요. data?.items ?? [] 같은 방어 코드는 원인을 확인한 뒤에 넣는 게 좋아요.
curl로 브라우저와 똑같은 요청을 보내려면 어떻게 하나요?
개발자 도구 Network 탭에서 요청을 우클릭하고 Copy as cURL을 선택하면 헤더와 쿠키, 본문을 포함한 명령이 복사돼요. 터미널에 붙여 넣고 -i 를 추가하면 응답 헤더까지 볼 수 있어요. 공유할 때는 인증 토큰과 쿠키를 지우세요.
브라우저에선 되는데 서버(Node)에서 호출하면 안 돼요.
브라우저는 쿠키, User-Agent, Referer 같은 헤더를 자동으로 붙이는데 서버 코드엔 없어서 거절되는 경우가 많아요. 반대로 서버에선 CORS 제약이 없으니, 브라우저만 안 된다면 CORS 문제예요. Copy as cURL로 받은 헤더와 서버 요청의 헤더를 비교해 보세요.
정리
API 문제는 바깥에서 안쪽으로 좁히면 빨라요. 요청이 나갔는지, 상태 코드와 Content-Type은 무엇인지 확인하고, 본문을 펼쳐 기대값과 나란히 놓은 다음에야 코드를 봐요. 1~5단계에서 원인이 잡히면 코드를 뜯어고칠 필요가 없고, 6단계까지 왔다면 범위가 이미 좁혀져 있어요.
응답 구조를 펼칠 땐 JSON 포맷터, 문법만 빠르게 볼 땐 JSON 검증기를 쓰세요. 포맷 자체를 고민 중이라면 JSON·YAML·XML 차이도 참고가 될 거예요.