Frontend 27
fetch로 JSON 데이터 완전 정복: 두 번의 await와 HTTP의 원리
json-server를 띄우고 fetch를 호출하면 왜 두 번 await해야 하는지, HTTP 메서드와 JSON 데이터가 어떻게 연결되는지 헷갈린다.
근거 · 교안 p200-p210
1장: 클라이언트와 서버의 대화: HTTP 요청과 응답
솔라의 검지가 노트북 화면을 가리켰다. 커서는 깜빡이고 있었지만, 새로운 코드는 좀처럼 나타나지 않았다. 모니터 한구석에 띄워 놓은 문서에는 간결한 문장이 적혀 있었다. ‘클라이언트와 서버는 HTTP 요청/응답으로 대화한다. 데이터 생성, 조회, 수정, 삭제(CRUD)는 각각 POST, GET, PATCH, DELETE 메서드에 대응된다.’
아는 단어들의 조합이었다. 클라이언트, 서버, HTTP, GET. 솔라는 지금 만들고 있는 작은 블로그 앱의 ‘클라이언트’에서 게시글 목록을 가져오고 싶었다. 로컬에서 실행 중인 ‘서버(json-server)‘에 데이터가 있다는 것도 안다. 그러니 당연히 GET 요청을 보내면 될 터였다.
하지만 코드는 생각처럼 흐르지 않았다. fetch 함수를 호출하는 한 줄의 코드가 자꾸만 발목을 잡았다. 머릿속에서는 단순한 그림이 그려졌다. “저기요, 게시글 목록 좀 주세요(GET).” 그러면 서버가 게시글 목록(JSON 데이터)을 건네주면 끝 아닌가? 그런데 왜 fetch는 약속(Promise)이니, 응답(Response)이니 하며 바로 데이터를 주지 않는 걸까. 마치 간단한 심부름을 시켰는데, 물건은 안 가져오고 영수증만 먼저 툭 던져주는 느낌이었다.
“대체 이 ‘대화’라는 게 어떻게 돌아가는 거야?”
솔라는 혼잣말과 함께 의자 등받이에 몸을 기댔다. 분명 서버는 데이터를 가지고 있고, 나는 그걸 달라고 말하고 있는데, 중간에 뭔가 보이지 않는 절차가 끼어 있는 것 같았다.
“언니, 잠깐만.”
솔라는 노트북을 들고 거실로 나갔다. 소파에 앉아 태블릿을 보던 루나가 고개를 들었다.
“이것 좀 봐. 서버에 있는 게시글 목록을 fetch로 가져오려고 하는데, 생각대로 안 돼. GET 요청을 보내면 서버가 그냥 JSON 데이터를 바로 주면 되는 거 아니야? 왜 이렇게 복잡하게 구는 거지?”
솔라의 목소리에는 답답함이 묻어났다. 루나는 솔라의 노트북 화면에 떠 있는 fetch('http://...') 코드를 잠시 들여다보았다. 그리고는 솔라의 말에 담긴 가정을 정확히 짚어냈다.
“서버가 ‘JSON 데이터’만 툭 던져준다고 생각하는구나.”
“응. 내가 요청한 게 그거니까.”
“좋아. 그럼 브라우저나 fetch가 끼어들기 전에, 서버랑 직접 대화해보면 어떨까? 둘이 정말로 어떤 말을 주고받는지 엿들어보는 거야.”
루나는 자신의 태블릿으로 검은 터미널 창을 열었다. 솔라가 로컬에서 실행 중인 서버 주소를 입력했다.
curl -v http://localhost:3000/posts
엔터 키를 누르자, 익숙한 데이터 덩어리와 함께 평소에는 보지 못했던 여러 줄의 텍스트가 화면을 채웠다.
“우와, 이게 다 뭐야? 맨 아래에 내가 아는 데이터가 있긴 한데….” 솔라가 게시글 목록을 가리키며 말했다.
“이게 바로 너와 서버가 나눈 ‘대화’의 원본이야.”
루나는 > 기호로 시작하는 줄들을 먼저 가리켰다.
> GET /posts HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.81.0
> Accept: */*
“이게 네가 보낸 편지야. curl이라는 우편배달부가 서버에 전달한 거지. 가장 중요한 내용은 첫 줄에 있어. ‘GET /posts’, 즉 ‘posts 경로에 있는 리소스를 조회(GET)하고 싶습니다’라는 대화의 목적이 담겨있지.”
“편지의 목적… 그렇구나. GET은 그냥 내가 뭘 원하는지 말하는 거였네.”
솔라가 고개를 끄덕였다. 그녀는 GET이라는 단어가 데이터의 종류나 형식을 결정한다고 막연히 오해하고 있었다.
“그럼 이건?”
솔라가 이번에는 < 기호로 시작하는 응답 부분을 가리켰다.
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 438
...
[
{ "id": 1, "title": "json-server", "author": "typicode" },
...
]
“이건 서버가 보낸 답장. 편지를 잘 받았고, 네가 요청한 것을 보내주겠다는 신호야. 첫 줄의 200 OK가 바로 그 뜻이지. ‘대화 성공’이라는 뜻으로 봐도 좋아.”
루나의 손가락이 Content-Type: application/json이라는 줄에서 멈췄다.
“그리고 이 부분이 중요해. 서버는 내용물을 보내주기 전에, 이 편지 안에 뭐가 들었는지 미리 알려줘. ‘이 안에 든 건 JSON 형식의 텍스트입니다’ 하고 말이야. 네가 원했던 ‘게시글 목록’은 이 모든 정보가 담긴 포장지 안에, 내용물로서 들어있는 거지.”
솔라의 눈이 동그래졌다. 그녀는 curl이 출력한 내용을 처음부터 다시 훑어보았다. 요청 메시지, 응답 상태, 내용물에 대한 설명, 그리고 실제 내용물. 모든 것이 하나의 거대한 구조를 이루고 있었다. 데이터는 그저 그 구조의 일부일 뿐이었다.
“아… 그러니까 내가 GET으로 요청을 보내면, 서버는 ‘알았어, 여기!’ 하면서 데이터만 휙 던져주는 게 아니었구나. ‘요청 잘 받았고, 성공적으로 처리했어. 네가 받을 건 JSON 데이터고, 길이는 이 정도야. 자, 이제 진짜 데이터를 줄게.’ 이렇게 완전한 문장으로 대답해주는 거였어.”
“바로 그거야.” 루나가 말했다. “HTTP 통신은 단지 데이터를 던지고 받는 행위가 아니라, 서로 약속된 형식에 맞춰 메시지를 주고받는 ‘대화’에 가까워. 우리가 사람과 대화할 때 대화의 목적이 있고, 그에 맞는 격식과 절차가 있는 것처럼.”
솔라는 이제야 처음 봤던 그 문장이 다르게 보였다. ‘클라이언트와 서버는 HTTP 요청/응답으로 대화한다.’ 이제는 그 ‘대화’라는 단어의 무게가 느껴졌다. 요청은 편지의 목적을, 응답은 그 목적에 대한 답변과 내용물을 감싼 하나의 ‘소포’를 전달하는 과정이었다.
새로운 이해는 곧 새로운 질문을 낳았다.
“알겠어. 서버가 데이터만 주는 게 아니라, 상태 코드나 헤더 같은 정보가 담긴 완전한 응답 소포를 보낸다는 건 이해했어. 그런데 그러면… 내 코드에서 fetch를 호출했을 때, 그 함수가 나한테 처음 건네주는 건 대체 뭐야? 그 소포 전체를 말하는 건가? 그래서 바로 데이터를 못 꺼내는 거였어?“
2장: fetch의 첫 번째 await: Response 객체
솔라는 자기 방으로 돌아와 노트북을 열었다. 더 이상 에디터의 깜빡이는 커서만 보고 있지 않았다. 화면 한쪽에는 브라우저의 개발자 도구 콘솔이 활짝 열려 있었다. 방금 전 언니와 함께 터미널에서 봤던 curl의 결과가 머릿속을 맴돌았다. ‘대화의 원본’이라던 그 모습.
솔라는 키보드에 손을 올렸다. 이제는 curl이 아니라, 브라우저의 도구인 fetch를 직접 시험해볼 차례였다. 콘솔에 조심스럽게 코드를 입력했다.
const res = await fetch('http://localhost:3000/posts');
엔터 키를 누르자, undefined라는 메시지와 함께 다음 줄로 넘어갔다. 아무 일도 일어나지 않은 것 같았지만, 솔라는 res라는 변수에 무언가 담겼을 거라고 확신했다. 그녀는 침을 삼키고 res를 입력한 뒤 다시 엔터를 쳤다.
콘솔에 객체 하나가 펼쳐졌다. 하지만 기대했던 게시글 배열이 아니었다.
Response {
body: ReadableStream,
bodyUsed: false,
headers: Headers {},
ok: true,
redirected: false,
status: 200,
statusText: "OK",
type: "cors",
url: "http://localhost:3000/posts"
}
솔라는 눈을 가늘게 떴다. status: 200, statusText: "OK". 이건 아까 curl의 응답에서 봤던 첫 줄, ‘대화 성공’ 신호와 똑같았다. headers도 있었다. 이건 분명 서버가 보낸 ‘응답 소포’의 일부였다. 하지만 정작 가장 중요한 내용물, 게시글 데이터는 보이지 않았다. 대신 body: ReadableStream이라는 생소한 글자만 덩그러니 놓여 있었다.
“이게… 뭐야? 데이터가 아니라 영수증만 받은 느낌인데. 아니, 영수증이라기엔 뭔가 정보가 더 많고… 소포의 송장인가?”
fetch를 await까지 했는데도 바로 데이터를 얻지 못했다는 사실에 솔라는 다시 미궁에 빠진 기분이었다. 분명 ‘기다렸는데’, 왜 아직 내용물을 못 보는 걸까.
그때 방문이 열리며 루나가 들어왔다.
“어때, 브라우저랑은 대화 좀 통했어?”
“언니, 이것 좀 봐.” 솔라가 화면을 가리켰다. “분명 await로 기다렸는데, 왜 데이터를 안 주고 이런 걸 주는 거야? status가 200인 걸 보니 성공한 건 맞는데.”
루나는 콘솔에 출력된 Response 객체를 잠시 들여다봤다. 솔라의 말을 가만히 듣던 루나가 물었다.
“fetch가 하는 일이 뭐라고 생각해?”
“서버에 가서 데이터를 가져오는 거.” 솔라가 즉답했다.
“맞아. 그런데 ‘가져오는’ 과정은 생각보다 여러 단계로 나뉘어. 특히 네트워크에서는 말이야. fetch는 성격이 아주 급한 배달원 같아.”
루나는 솔라의 책상에 놓인 빈 택배 상자를 가리켰다.
“네가 인터넷으로 물건을 주문했다고 생각해 봐. 배달원이 도착하면 제일 먼저 뭘 하지? ‘주문하신 물건 도착했습니다’ 하고 벨을 누르겠지. 그리고 너에게 상자를 건네줄 거야. 그 상자에는 송장이 붙어있을 거고. 누가 보냈는지, 내용물이 파손되진 않았는지 같은 기본 정보를 확인할 수 있지.”
솔라는 콘솔의 Response 객체와 택배 상자를 번갈아 봤다. status: 200, ok: true. ‘아, 파손 없이 잘 도착했다’는 뜻이었구나.
“fetch가 바로 그 일을 하는 거야.” 루나가 말을 이었다. “서버에 요청을 보내고, 서버가 ‘응답을 시작할게!’라고 신호를 보내자마자, 그 응답의 ‘헤더’와 ‘상태’ 정보를 담은 이 Response 객체를 너에게 바로 던져주는 거지. 이게 첫 번째 await의 결과물이야. 진짜 데이터, 즉 내용물이 전부 다운로드될 때까지 멍하니 기다리지 않아.”
“기다리지 않는다고? await를 썼는데?” 솔라가 되물었다.
“첫 번째 await는 ‘서버로부터 응답 신호가 올 때까지’ 기다리는 거야. ‘모든 데이터 다운로드가 끝날 때까지’가 아니고. 일단 응답이 시작되면, fetch는 약속을 지킨 셈이니 ‘송장’이 붙은 이 Response 객체를 먼저 반환하고 자기 할 일은 끝났다고 보는 거지. 덕분에 우리는 실제 데이터를 읽기 전에 ‘요청이 성공했나?’, ‘데이터 형식이 진짜 JSON이 맞나?’ 같은 중요한 정보를 먼저 확인할 수 있어.”
그제야 솔라의 머릿속에 흩어져 있던 조각들이 맞춰졌다. fetch는 데이터를 가져다주는 함수가 맞지만, 그 과정을 두 단계로 나눈 것이었다.
- 요청을 보내고, 서버가 응답을 시작하면 ‘송장’에 해당하는
Response객체를 즉시 반환한다. (첫 번째 Promise와await) - 그
Response객체를 이용해 실제 ‘내용물’을 꺼낸다. (아직 모르는 두 번째 단계)
솔라는 마우스를 움직여 콘솔에 찍힌 Response 객체의 body: ReadableStream 부분을 가리켰다.
“그럼 이건… 아직 뜯지 않은 상자 속 내용물이란 뜻이네. ReadableStream… 읽을 수 있는 흐름이라니. 데이터가 강물처럼 흘러 들어오고 있다는 건가?”
“정확해. 특히 데이터가 클 경우, 한 번에 받는 게 아니라 조각조각 스트림 형태로 들어오거든. 브라우저는 그 스트림을 처리할 준비가 되었다는 신호로 ReadableStream을 보여주는 거야. 상자는 받았지만, 아직 뚜껑은 열지 않은 상태지.”
솔라는 고개를 끄덕였다. 이제 fetch가 곧장 데이터를 반환하지 않는 이유를 알 것 같았다. 그건 오류나 불편함이 아니라, 오히려 효율성을 위한 설계였다. 송장을 먼저 확인하고 내용물을 처리할지 결정할 기회를 주는 것.
새로운 사실을 깨닫자 새로운 의문이 고개를 들었다.
“좋아. 그럼 이 Response 객체, 그러니까 송장이 붙은 이 상자를 받았어. 이제 내용물을 꺼내야지. .json()이라는 함수가 있던데, 이걸 쓰면 되는 거 맞지?”
솔라는 자신 있게 키보드를 두드렸다.
res.json()
하지만 그녀의 기대와 달리, 콘솔에는 게시글 배열이 아닌 또 다른 단어가 나타났다.
Promise {<pending>}
“어? 또 Promise야? 상자 뚜껑을 여는 것도 기다려야 하는 일이라고?“
3장: 두 번의 await: 비동기 데이터 추출의 비밀
솔라의 방, 노트북 화면에는 브라우저 콘솔이 여전히 열려 있었다. 방금 전 res.json()을 실행하고 얻은 결과, Promise {<pending>}이라는 글자가 그녀를 빤히 쳐다보는 것 같았다. 약속. 또 약속이라니. 서버와의 대화에서 성공했다는 송장(Response 객체)까지는 잘 받아왔는데, 정작 내용물을 꺼내려는 순간 또다시 ‘기다리라’는 말을 들은 셈이다.
솔라는 미간을 찌푸렸다. 그녀의 오해가 무엇이었는지 명확했다. 그녀는 .json() 함수가 Response 객체 안에 이미 들어있는 텍스트를 그저 자바스크립트 객체로 ‘변환’만 해주는 동기적인 작업일 거라고 생각했다. 택배 상자를 받았으니, 뚜껑을 여는 건 즉시 할 수 있는 일이라고 믿었던 것이다. 하지만 브라우저는 상자 뚜껑을 여는 것조차 ‘시간이 걸리는 약속’이라고 말하고 있었다.
“이건 말이 안 돼.”
솔라는 혼잣말을 하며 키보드를 다시 두드렸다. 오기가 생겼다. 그녀는 방금 전 fetch로 받았던 변수 res는 그대로 둔 채, 콘솔에서 await 키워드를 붙여 같은 명령을 다시 한번 시도해보기로 했다.
const posts = await res.json();
엔터. 그러자 에러 메시지가 떴다. TypeError: Body has already been used. 이미 한 번 읽으려고 시도한 몸체는 다시 읽을 수 없다는 뜻이었다. “아, 맞다. 스트림은 한 번 흐르면 끝이지.” 솔라는 재빨리 처음부터 다시 데이터를 요청하는 코드를 작성했다.
// 다시 깨끗한 Response 객체를 받는다
const newRes = await fetch('http://localhost:3000/posts');
// 이번에는 await를 붙여서 .json()을 호출한다
const realPosts = await newRes.json();
두 줄의 코드를 실행하고, 솔라는 마지막으로 realPosts 변수를 콘솔에 입력했다.
console.log(realPosts);
드디어, 화면에 그토록 보고 싶었던 게시글 배열이 나타났다.
[ { id: 1, title: 'json-server', ... }, { ... } ]
솔라는 두 개의 결과를 번갈아 보았다. await가 없을 때의 Promise, 그리고 await가 있을 때의 실제 데이터. 똑같은 .json() 함수를 호출했는데 결과는 하늘과 땅 차이였다.
“어? 또 Promise야? 상자 뚜껑을 여는 것도 기다려야 하는 일이라고?”
방금 전 솔라의 탄식을 들었던 루나가 어느새 방 문에 기대어 서 있었다. 그녀는 솔라의 화면에 나란히 놓인 두 개의 다른 결과를 보고 희미하게 미소 지었다.
“그 질문에 대한 답을 네가 방금 직접 증명했네.”
“내가? 나는 그냥 await를 붙여봤을 뿐인데… 왜, 왜 json()도 기다려야 하는 거야? Response 객체 안에 데이터가 다 들어있는 거 아니었어?”
솔라의 목소리에는 여전히 혼란이 가득했다. 루나는 솔라의 책상으로 다가와, 아까 전 솔라가 Response 객체를 처음 확인했을 때의 화면을 가리켰다.
“이 Response 객체를 다시 봐봐. body가 뭐라고 쓰여있지?”
“ReadableStream… 읽을 수 있는 흐름.”
“바로 그거야. 핵심은 ‘흐름’이라는 단어야. 네가 서버로부터 받은 응답의 본문은 이미 완성된 편지가 아니라, 수도꼭지에서 흘러나오는 물줄기 같은 거야.”
루나는 말을 이었다.
“.json() 함수는 네 생각보다 훨씬 많은 일을 해. 그 함수는 단순히 ‘JSON 텍스트를 객체로 바꾸는’ 일이 아니야. 그보다는 ‘수도꼭지를 틀어서 물이 다 나올 때까지 받아서, 그 물의 양을 재고, 내용물을 분석하는’ 과정에 가까워.”
솔라의 눈이 커졌다.
“물이 다 나올 때까지 받는다고?”
“응. 네트워크를 통해 데이터가 들어오는 스트림을 끝까지 읽는 작업이 필요해. 데이터가 아주 클 수도 있잖아? 1메가바이트짜리 JSON 데이터가 있다고 상상해봐. 그게 0.1초 만에 네 컴퓨터에 도착할까? 아니지. 네트워크 속도에 따라 조각조각, 순서대로 흘러 들어올 거야. .json()은 그 조각들을 모두 모아서 하나의 완전한 텍스트로 합치는 과정을 먼저 수행해. 그리고 그 과정이 모두 끝나야만, 비로소 그 텍스트를 JSON으로 파싱해서 객체로 만들 수 있지.”
그제야 솔라의 머릿속에서 모든 것이 연결되었다. 스트림. 흐름. 데이터가 도착하는 데는 시간이 걸린다. .json()은 그 시간을 기다려주는 역할을 포함하고 있었던 것이다.
“아! 그래서 Promise였구나! .json()이라는 이름 때문에 파싱만 생각했는데, 사실은 ‘스트림 끝까지 다 읽기’라는 눈에 보이지 않는 비동기 작업이 숨어있었던 거네. 읽는 게 끝나야 파싱을 하니까, 전체 과정은 당연히 비동기일 수밖에 없고.”
솔라는 자신의 원래 코드가 있던 에디터 창을 열었다. 텅 비어 있던 함수 안이 이제 어떻게 채워져야 할지 명확하게 보였다. 더 이상 망설일 필요가 없었다. 그녀는 확신을 담아 코드를 작성하기 시작했다.
async function fetchPosts() {
// 첫 번째 await: 서버에 요청을 보내고 '응답이 시작될 때까지' 기다린다.
// 성공하면 상태 코드와 헤더가 담긴 '송장(Response)'을 받는다.
const response = await fetch('http://localhost:3000/posts');
// 두 번째 await: 응답 본문(ReadableStream)의 '흐름이 끝날 때까지' 기다린다.
// 스트림을 모두 읽어 텍스트로 합치고, 그것을 JSON 객체로 파싱한 '내용물'을 받는다.
const posts = await response.json();
// 이제 posts는 진짜 자바스크립트 배열이다.
console.log(posts);
return posts;
}
솔라는 직접 작성한 fetchPosts 함수를 실행했다. 콘솔에는 아무런 오류 없이, 그녀가 원했던 깔끔한 게시글 데이터 배열이 출력되었다.
“됐다.”
짧은 한마디에 모든 과정에서 얻은 깨달음이 담겨 있었다. fetch의 두 번의 await는 더 이상 혼란스러운 마법이 아니었다.
첫 번째 기다림은 우편배달부에게서 ‘소포가 도착했다’는 소식을 듣는 약속. 두 번째 기다림은 그 소포의 끈을 풀고, 포장지를 벗겨, 내용물을 완전히 꺼내는 약속.
솔라는 이제 서버와 ‘대화’하고, 그 대화의 결과물을 안전하게 풀어내는 방법을 알게 되었다. 화면 속 코드는 그 명확한 증거였다.