Frontend 29
POST와 DELETE로 새 글과 삭제를 서버에 반영하기
화면에서는 글 추가와 삭제가 이미 됐는데 왜 서버에 POST와 DELETE를 따로 보내야 하는지, fetch options가 무엇인지 모르겠다.
근거 · 교안 p229-p242
1장: UI 변화가 전부가 아니라고? 로컬과 서버 상태
솔라는 의기양양했다. 동생의 얼굴에 좀처럼 보기 드문 뿌듯함이 가득한 것을 보고 루나는 조용히 화면을 들여다보았다. 솔라가 만든 작은 블로그 애플리케이션이었다. 목록에는 게시글 서너 개가 나란히 놓여 있었다.
“언니, 이것 봐. 내가 ‘오늘의 할 일’이라고 새로 글을 써볼게.”
솔라는 키보드를 몇 번 두드리더니 ‘글 추가’ 버튼을 자신 있게 눌렀다. 그러자 방금 입력한 ‘오늘의 할 일’이라는 제목의 새 글이 목록 맨 위에 부드럽게 나타났다. 화면이 즉각적으로 바뀌는 모습이 꽤 그럴듯했다.
“어때? 완벽하게 작동하지? 이제 글을 계속 추가하고 지울 수도 있어. UI를 바꾸는 건 이제 확실히 알겠어.”
솔라의 목소리에는 자부심이 묻어났다. 화면에 보이는 변화. 그것이 곧 완성이라고 믿는 듯했다. 루나는 고개를 끄덕였지만, 칭찬 대신 다른 질문을 던졌다.
“멋진데. 그럼 그 상태에서 새로고침 한번 해볼래?”
“새로고침?”
솔라는 질문의 의도를 파악하지 못한 채 어깨를 으쓱했다. 뭐 대수로운 일이랴. 그녀는 망설임 없이 브라우저의 새로고침 아이콘을 클릭했다. 페이지가 하얗게 변했다가 다시 로딩되었다.
그런데, 화면에 나타난 것은 원래 있던 서너 개의 게시글뿐이었다. 방금 솔라가 야심 차게 추가했던 ‘오늘의 할 일’은 온데간데없이 사라져 있었다.
“어?”
솔라의 얼굴에서 미소가 순식간에 사라졌다. 당황한 그녀는 다시 한번 ‘오늘의 할 일’을 입력하고 추가 버튼을 눌렀다. 글은 아까처럼 목록에 잘 나타났다. 하지만 다시 새로고침하자 또다시 감쪽같이 사라졌다. 마치 처음부터 없었던 것처럼.
“뭐야… 왜 이러지? 분명히 추가됐는데, 왜 새로고침만 하면 사라지는 거야? 저장이 안 되나?”
혼란에 빠진 솔라가 중얼거렸다. 루나는 그때서야 조용히 입을 열었다.
“솔라, 네가 방금 한 건 교실 화이트보드에 필기한 거랑 비슷해.”
“화이트보드?”
“응. 화이트보드에 쓴 글씨는 그 자리에 있는 동안에는 선명하게 잘 보이지. 지우개로 지우거나 다른 내용을 덧붙일 수도 있고. 하지만 쉬는 시간이 끝나고 다른 반 학생이 들어오거나, 선생님이 보드를 깨끗이 지워버리면 어떻게 될까?”
루나의 말에 솔라는 잠시 생각에 잠겼다. “당연히… 다 지워지지.”
“맞아. 네가 만든 애플리케이션의 ‘상태’가 지금 딱 그 화이트보드 같아. 브라우저라는 교실 안에서, 네가 글을 추가하면 화면이라는 보드에는 그 내용이 잘 보여. 하지만 새로고침은 교실을 나갔다가 다시 들어오는 것과 같아. 보드는 깨끗하게 지워진 채로 널 맞이하는 거지.”
루나는 솔라의 노트북 옆에 놓인 작은 메모지와 돌멩이를 집어 들었다.
“화면에 글이 보이는 건, 이 메모지에 연필로 글씨를 쓰는 것과 같아. 언제든 쓰고 지울 수 있지만, 바람이 불면 날아가 버릴 수도 있고, 누군가 구겨버리면 그만이야. 일시적인 기록이지. 이걸 컴퓨터의 ‘메모리’에만 존재하는 로컬 상태라고 부를 수 있어.”
루나는 말을 이었다. “하지만 네가 원하는 건 이게 아니잖아. 새로고침을 해도, 컴퓨터를 껐다 켜도, 심지어 다른 컴퓨터에서 접속해도 내가 쓴 글이 그대로 남아있는 것. 그건 여기에 새기는 것과 같아.”
루나는 손에 쥔 돌멩이로 책상을 가볍게 ‘탁’ 쳤다.
“돌에 글씨를 새기는 것처럼 말이야. 한번 새기면 지워지지도, 쉽게 사라지지도 않지. 이건 영구적인 기록이야. 이 돌멩이가 바로 ‘서버’고, 여기에 새겨진 기록이 서버 상태야.”
솔라는 그제야 고개를 끄덕였다. 사라진 글의 미스터리가 풀리는 순간이었다. 그녀는 자신의 코드를 다시 쳐다보았다. 글을 추가하는 함수는 그저 화면에 보이는 목록 배열에 새 항목을 밀어 넣고 있을 뿐이었다. 그 어디에도 ‘서버’라는 돌멩이에게 ‘이 글자를 새겨줘!’라고 말하는 코드는 없었다.
“아… 그럼 내가 한 건 그냥 내 컴퓨터 화면에만 임시로 그림을 그리고 있었던 거구나. 서버라는 진짜 기록부에는 아무런 요청도 보내지 않았던 거고. 그러니까 새로고침하면 전부 날아갔던 거네.”
자신의 실수가 어디에 있었는지 명확히 깨달은 솔라의 얼굴에 다시 호기심이 어렸다. 이제는 ‘왜’가 아니라 ‘어떻게’가 궁금해졌다. 화이트보드가 아니라 돌에 새기는 법. 그것만 알면 될 것 같았다.
“알겠어, 언니. 내 화면은 그냥 메모장이었던 거구나. 그럼 이 메모장의 내용을 저기 단단한 돌멩이, 서버에 진짜로 새기려면 어떻게 해야 해? 강의에서 얼핏 봤던 await fetch라는 게 혹시… 돌을 깎는 조각칼 같은 역할을 하는 거야?”
2장: 서버와 대화하는 법: HTTP 메서드와 fetch 옵션
솔라의 ‘조각칼’이라는 비유에 루나는 희미하게 미소 지었다. 좋은 출발점이었다. 루나는 대답 대신, 솔라의 노트북 화면에 떠 있는 코드 편집기로 시선을 옮겼다. 솔라가 만든, 이제는 불완전하다는 것이 명백해진 글 추가 함수가 보였다.
// 솔라의 코드
function createHandler(newPost) {
const updatedPosts = [...posts, newPost];
// ... 화면을 업데이트하는 코드
}
루나는 그 옆에 새로운 코드 덩어리를 타이핑하기 시작했다. 마치 빈 양식을 채우라는 듯한, 낯설지만 어딘가 익숙한 구조였다.
fetch('/topics', {
method: '',
headers: {
'Content-Type': ''
},
body: ''
});
솔라는 고개를 갸웃하며 새로운 코드 블록을 쳐다봤다. “이건… fetch인데, 괄호 안에 뭐가 엄청 많네. 지난번엔 그냥 주소만 넣어서 데이터를 가져왔는데, 이건 뭐지?”
“조각칼은 맞아.” 루나가 조용히 입을 열었다. “하지만 서버라는 돌에 무작정 칼을 댈 수는 없어. 석공에게 제대로 된 주문서를 보내야지. 그냥 돌멩이만 툭 던져주는 게 아니라.”
루나는 솔라가 쳐다보고 있는 빈 양식을 손가락으로 가리켰다. “이게 바로 서버에게 보내는 주문서, 혹은 소포라고 생각해 봐. 우리가 저번에 쓴 fetch는 그냥 ‘거기 있는 것 좀 보여줘’라고 소리치는 거랑 비슷해. 하지만 지금 우리는 무언가를 ‘보내서’ 기록하게 만들어야 하잖아.”
루나는 method: '' 라고 쓰인 부분을 짚었다. “여기가 가장 중요해. 소포의 목적을 쓰는 칸이야. ‘새로운 물건을 추가해주세요’라고 명확하게 알려줘야 해. 우리는 새 글을 ‘게시(Post)’할 거니까, 여기엔 'POST'라고 적는 거야.”
솔라는 고개를 끄덕이며 자신의 createHandler 함수 안에 fetch 구문을 조심스럽게 옮겨 적기 시작했다. method: 'POST'. 단순한 단어 하나였지만, 서버에게 말을 건네는 첫 단계처럼 느껴졌다.
“그럼 headers는 뭐야? 머리말?”
“소포 상자에 붙이는 안내 스티커 같은 거야. ‘취급주의’나 ‘내용물: 책’처럼 말이야. 받는 사람, 즉 서버가 내용물을 열어보기 전에 어떻게 다뤄야 할지 알려주는 거지.” 루나는 'Content-Type': '' 부분을 가리켰다. “여기에 'application/json'이라고 쓰면, ‘이 소포 안에는 JSON이라는 형식의 데이터가 들어있어요’라고 알려주는 셈이야. 서버는 이걸 보고 ‘아, JSON 형식으로 온 데이터구나. 그렇게 해석해야지’ 하고 준비할 수 있어.”
“JSON… 우리가 서버에서 데이터 가져올 때 쓰던 그 형식 맞지?”
“응, 맞아. 이제 우리가 서버에게 데이터를 보낼 때도 같은 형식을 쓰는 거야. 서로 알아들을 수 있는 언어로 대화하는 거지.”
루나는 마지막으로 body: ''를 가리켰다. “여기가 바로 소포의 내용물, 우리가 서버에 새기고 싶은 진짜 데이터가 들어가는 곳이야. 네가 방금 쓴 ‘오늘의 할 일’이라는 글 제목 같은 것들.”
루나는 말을 이었다. “단, 조건이 있어. 소포에 물건을 넣을 때, 그냥 욱여넣는 게 아니라 운송 중에 망가지지 않게 잘 포장해야 하잖아? 컴퓨터 세상에서는 데이터를 ‘문자열’로 바꿔서 보내야 안전해. JSON.stringify()라는 포장지로 우리가 만든 객체를 감싸서 보내는 거지.”
솔라의 눈이 반짝였다. method, headers, body. 흩어져 있던 퍼즐 조각들이 하나의 그림으로 맞춰지는 기분이었다. 이건 그냥 함수 호출이 아니었다. 서버와 소통하기 위한 정교한 규칙, 하나의 약속이었다. 그녀는 자신의 함수를 마저 완성했다.
async function createHandler(newPost) {
await fetch('/topics', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newPost)
});
// ... 아직은 그대로인 화면 업데이트 코드
}
“좋아, 한번 해볼게.” 솔라는 코드를 저장하고는, 실행하기 전에 잠시 망설였다. 루나가 그런 솔라의 마음을 읽었다는 듯 말했다. “브라우저에서 개발자 도구를 열고 ‘네트워크(Network)’ 탭을 먼저 켜봐. 우리가 보낸 소포가 진짜로 배송되는지 지켜보자고.”
솔라는 개발자 도구를 켜고 네트워크 탭을 활성화했다. 그리고 ‘글 추가’ 버튼을 눌렀다.
순간, 네트워크 탭에 새로운 줄이 나타났다. topics, 상태 201, 메서드 POST. 심장이 두근거렸다. 솔라는 그 줄을 클릭했다. 오른쪽에 나타난 상세 정보 창에서 ‘헤더(Headers)’ 섹션을 스크롤하자 Request Headers 아래에 자신이 코드에 썼던 Content-Type: application/json이 선명하게 보였다. 그리고 ‘페이로드(Payload)’ 탭을 누르자, 방금 자신이 입력한 글의 내용이 담긴 JSON 문자열이 나타났다.
{"title":"새로운 글", "author":"솔라"}
“우와…!” 탄성이 터져 나왔다. “이게… 내가 보낸 소포구나! method는 주문 내용, headers는 안내 스티커, body는 진짜 내용물. 그냥 fetch라고만 생각했는데, 이건 완전 서버용 택배 송장이었어!”
솔라는 이제 fetch의 options 객체가 더 이상 암호문처럼 보이지 않았다. 서버에 무언가를 요구하기 위해 정성껏 포장한 소포였다. 그녀는 이제 서버에 데이터를 보내는 방법을 확실히 이해했다. 새로고침을 하자, 방금 추가한 글은 사라지지 않고 목록에 당당히 남아있었다. 돌에 글씨가 새겨진 것이다.
성공의 기쁨도 잠시, 솔라의 표정에 새로운 질문이 떠올랐다.
“언니, 그런데 이상한 점이 있어. 내 코드는 그냥 fetch로 소포를 보내기만 했잖아. 서버가 이걸 잘 받았는지, 아니면 주소가 틀려서 반송됐는지 어떻게 알지? 지금은 성공했으니까 다행이지만, 만약 실패했다면? 내 화면은 성공한 것처럼 글을 보여줬을 텐데… 우리는 서버로부터 ‘배송 완료’ 영수증 같은 걸 받아야 하는 거 아니야?”
3장: await fetch 이후, 화면과 서버 상태 동기화
솔라의 질문은 성공의 기쁨에 섞인 작은 불안감이었다. 그녀의 화면에 떠 있는 코드는 분명 작동했다. 글은 서버에 잘 저장되었고, 새로고침해도 사라지지 않았다. 하지만 그녀의 지적대로, 코드는 소포를 보내기만 할 뿐, 배송이 완료되었는지 확인하는 절차가 빠져 있었다. 만약 배송 사고가 났다면? 화면은 성공한 것처럼 보이지만, 서버에는 데이터가 없는 유령 상태가 될 것이다.
루나는 말없이 솔라의 코드 편집기에서 createHandler 함수를 복사해 그 아래에 붙여넣었다. 그리고는 두 번째 함수의 코드 순서를 살짝 바꾸었다.
// 솔라의 현재 코드
async function createHandler(newPost) {
// 1. 서버에 소포를 보낸다.
await fetch('/topics', { /* ... options ... */ });
// 2. 화면을 업데이트한다.
const updatedPosts = [...posts, newPost];
setPosts(updatedPosts);
}
// 루나가 수정한 코드
async function createHandler_Risky(newPost) {
// 1. 화면을 먼저 업데이트한다.
const updatedPosts = [...posts, newPost];
setPosts(updatedPosts);
// 2. 그 다음에 서버에 소포를 보낸다.
await fetch('/topics', { /* ... options ... */ });
}
“두 함수가 있어, 솔라. 하나는 네가 쓴 거고, 다른 하나는 내가 순서만 바꾼 거야. 사용자 입장에서 보면 결과는 똑같아. 글을 추가하면 화면에 바로 나타나지. 어느 쪽이 더 나은 코드일까?”
솔라는 두 코드를 비교하며 미간을 좁혔다. “음… 아래쪽 코드가 사용자 경험은 더 좋을 것 같은데? 버튼을 누르자마자 화면이 바로 바뀌니까. 서버에 요청 보내는 데 시간이 좀 걸릴 수도 있잖아.”
“정확해. 그걸 ‘낙관적 업데이트(Optimistic Update)’라고 불러. 성공할 거라고 낙관하고 화면을 먼저 바꾸는 거지.” 루나가 말했다. “하지만 네가 아까 걱정한 문제가 바로 여기서 생겨. 만약 fetch 요청이 인터넷 연결 문제나 서버 오류로 실패하면 어떻게 될까?”
솔라의 눈이 커졌다. “아…! 화면에는 새 글이 추가된 것처럼 보이는데, 실제 서버에는 저장이 안 된 상태가 되겠구나. 그러다 새로고침하면 글이 다시 사라질 거고. 사용자는 자기가 쓴 글이 날아갔다고 생각하겠네.”
“바로 그거야. ‘배송 완료’ 영수증을 확인하지 않고 ‘배송 시작!’이라는 말만 믿은 셈이지.” 루나는 솔라의 원래 코드로 돌아왔다. “fetch 함수는 그냥 소포를 보내고 끝나는 게 아니야. 반드시 답장을 가지고 돌아와. 그게 바로 영수증이야.”
루나는 솔라의 코드 한 줄을 수정했다.
const response = await fetch('/topics', { /* ... options ... */ });
console.log(response);
“이 response라는 변수에 서버의 답장이 담겨. await는 그 답장이 올 때까지 기다리겠다는 뜻이고. 한번 이 상태로 글을 추가하고 콘솔을 확인해볼래?”
솔라는 코드를 수정하고 다시 새 글을 추가했다. 그리고 개발자 도구의 콘솔 탭을 열었다. 복잡해 보이는 객체 하나가 출력되어 있었다.
Response {
type: 'basic',
url: 'http://localhost:3000/topics',
redirected: false,
status: 201,
ok: true,
statusText: 'Created',
// ... 등등
}
“우와, 뭐가 엄청 많네.” 솔라가 중얼거렸다.
“다 볼 필요는 없어. 저기 ok랑 status 프로퍼티를 봐.” 루나가 화면을 가리켰다. “status가 201이면 ‘성공적으로 생성됨’이라는 약속된 코드야. 그리고 ok가 true라는 건, 요청이 200번대의 성공적인 상태 코드로 응답했다는 걸 의미해. 이게 바로 서버가 보내는 ‘배송 완료’ 영수증의 핵심이야. ‘네 요청 잘 받아서 처리했어!’ 라는 뜻이지.”
이제야 모든 조각이 맞춰졌다. await fetch는 단순히 서버에 요청을 던지는 행위가 아니었다. 서버의 ‘응답’을 기다리고, 그 결과를 받아보는 과정 전체를 포함하는 것이었다.
“알겠다! 그럼 화면을 업데이트하기 전에, 이 영수증이 진짜인지 확인해야 하는 거구나.”
솔라는 신나게 키보드를 두드리기 시작했다. 그녀의 머릿속에 흩어져 있던 로직이 명확한 순서로 정리되고 있었다.
- 서버에
fetch로 요청을 보낸다. (await fetch) - 서버의 응답(
response)을 받는다. - 응답의
ok프로퍼티가true인지 확인한다. (영수증 확인) true일 경우에만 화면의 상태를 업데이트한다.
“그런데 언니, 한 가지 더. 서버가 응답으로 ‘성공했어’라는 말만 해주는 거야? 아니면 내가 보낸 내용물도 다시 돌려줘?”
“아주 중요한 질문이야.” 루나가 대답했다. “서버는 보통 데이터를 성공적으로 저장한 후에, 서버에 저장된 최종 데이터를 다시 보내줘. 예를 들어, 서버가 데이터에 고유한 id를 붙여서 저장했다면, 그 id가 포함된 완전한 데이터를 응답으로 주는 거지.”
루나는 response.json()이라는 코드를 추가로 알려주었다. 서버가 보내준 JSON 형식의 내용물을 실제 자바스크립트 객체로 변환해주는 함수였다. 솔라는 배운 내용을 모두 종합하여 최종 코드를 완성했다.
async function createHandler(newPost) {
const response = await fetch('/topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost)
});
if (response.ok) {
const savedPost = await response.json(); // 서버에 저장된 최종 데이터
const updatedPosts = [...posts, savedPost]; // 서버 데이터를 기준으로 화면 업데이트
setPosts(updatedPosts);
} else {
alert("오류: 글을 저장하지 못했습니다.");
}
}
코드가 완벽해졌다. 이제는 서버의 성공적인 응답을 기반으로 화면을 그린다. 메모리 상태(로컬)의 변경이 서버 상태의 확인 뒤로 온 것이다. ‘메모리 state 변경 앞에 await fetch가 추가되면 새로고침해도 유지되는 서버 반영이 된다’는 강의 노트의 한 문장이 비로소 온전히 이해되었다. fetch의 성공을 기다리고, 그 응답을 확인한 뒤에야 메모리를 변경해야 한다는 뜻이었다.
솔라는 마지막으로 한 가지를 더 확인하고 싶었다. 그녀는 글 삭제 버튼에 연결될 deleteHandler 함수를 만들기 시작했다. 이제는 루나의 도움이 필요 없었다.
“삭제할 때도 똑같겠지?” 그녀는 혼잣말처럼 중얼거리며 코드를 짜 내려갔다. “먼저 서버에 method: 'DELETE'로 삭제 요청을 보내고, response를 기다려. 응답이 ok이면, 그때 화면에서 해당 글을 지우는 거야. 절대로 먼저 지우면 안 돼.”
솔라의 손가락이 자신감 있게 움직였다. 그녀는 더 이상 화면의 즉각적인 변화에 속지 않았다. 눈에 보이는 것과 진짜 기록된 것의 차이를, 그리고 그 둘을 동기화하는 가장 안전한 다리가 서버의 ‘응답’이라는 사실을 깨달았기 때문이다. 그녀는 이제 돌멩이에 글씨를 새길 뿐만 아니라, 그 글씨가 잘 새겨졌는지 확인하고 나서야 사람들에게 ‘여기에 글이 있다’고 알려주는, 신중하고 믿음직한 석공이 되어 있었다.