Frontend 30
PATCH로 좋아요 업데이트 완성하기
좋아요 하나를 올리는 데 왜 PATCH body를 보내고, 서버 응답 updated로 다시 map을 돌려야 하는지 모르겠다.
근거 · 교안 p243-p250
1장: PATCH 요청, 왜 ‘그것’만 보낼까?
솔라의 손가락이 키보드 위에서 잠시 멈칫했다. 화면에는 방금 막 완성한 ‘좋아요’ 기능의 업데이트 로직이 떠 있었다. 게시글을 새로 만들고(Create), 목록을 불러오고(Read), 삭제하는(Delete) 코드는 물 흐르듯 작성했다. 서버에 요청을 보내고, 응답을 받아 화면에 반영하는 흐름이 명쾌하게 느껴졌다.
하지만 업데이트(Update)는 달랐다. 무언가 낯선 구석이 있었다.
// ...
const handleLike = async (id, currentLikes) => {
const response = await fetch(`/posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
likes: currentLikes + 1
})
});
// ...
}
솔라는 body 부분을 몇 번이고 다시 쳐다봤다. 게시글 데이터는 id, author, content, likes 등 여러 필드로 이루어진 객체다. 그런데 ‘좋아요’ 수를 하나 올리는 요청에 likes 필드 하나만 덜렁 보내고 있었다. 마치 중요한 서류의 한 페이지만 찢어서 제출하는 기분이었다.
“언니, 잠깐만. 이거 좀 이상한데.”
등 뒤에서 조용히 책을 읽던 루나가 고개를 들었다.
“PATCH로 ‘좋아요’ 수를 업데이트하는데, 왜 body에 게시글 객체 전체가 아니라 { likes: ... } 이것만 담아서 보내? 업데이트라면 당연히 수정할 게시글의 전체 데이터를 보내서 서버가 그걸로 통째로 바꾸게 해야 안전한 거 아니야? 이러다 내용이나 작성자 정보가 날아가 버리면 어떡해?”
솔라의 목소리에는 ‘나는 당연히 이럴 줄 알았다’는 확신과 ‘그런데 왜 코드는 이 모양이지?’라는 혼란이 섞여 있었다. 데이터를 빠짐없이 전부 보내야 완전한 업데이트라고 생각했는데, 눈앞의 코드는 중요한 정보를 누락한 것처럼 보였다.
루나는 책을 덮고 솔라의 옆으로 다가왔다. 화면 속 코드를 잠시 들여다보더니, 말없이 책상 위 메모지 한 장을 가져왔다. 그리고 펜으로 네모 칸을 그리고 그 안에 필드 이름을 적기 시작했다.
{ id: 1, author: '솔라', content: '첫 게시글!', likes: 14 }
“이게 서버에 저장된 게시글 원본이라고 해보자.”
루나는 말을 이었다.
“솔라 네가 동사무소에 가서 주소 변경 신청을 한다고 생각해봐. 신청서에는 이름, 주민등록번호, 기존 주소, 바꿀 주소, 이렇게 네 가지 항목이 있어.”
솔라는 고개를 끄덕였다.
“이때, 너는 신청서의 ‘바꿀 주소’ 칸에만 새 주소를 적어서 내면 되지? 굳이 이름이나 주민등록번호까지 다시 써서 ‘이 사람의 모든 정보를 이걸로 바꿔주세요’라고 하진 않잖아.”
“그야 당연하지. 바꿀 건 주소 하나뿐인데. 괜히 다른 정보까지 건드렸다가 잘못 적기라도 하면 더 큰일이니까.”
솔라가 대답하자마자 루나가 펜으로 메모지의 likes: 14 부분에 동그라미를 쳤다.
“바로 그거야. 서버 입장에서도 똑같아. 지금 우리가 하려는 건 게시글의 수많은 정보 중에 ‘좋아요 수’ 딱 하나만 1 올리는 일이야. 서버에게 ‘1번 게시글의 다른 건 전부 그대로 두고, likes 값만 15로 바꿔줘’라고 알려주는 게 가장 정확하고 효율적이지 않을까?”
루나의 말에 솔라는 아, 하는 작은 탄성을 뱉었다. 동사무소 비유를 듣고 나니, 전체 데이터를 보내는 것이 오히려 비효율적이고 위험할 수 있겠다는 생각이 들었다.
“네가 쓴 코드에서 PATCH라는 메서드가 바로 그 ‘바꿀 부분만 알려주기’ 위한 약속이야. 그리고 body에 { likes: currentLikes + 1 } 이렇게 넣는 건, ‘다른 정보는 일절 언급하지 않을 테니 건드리지 말고, likes 필드만 이 값으로 콕 집어 수정해줘’라고 요청하는 명세서 같은 거지.”
솔라는 다시 자신의 코드를 바라보았다. 아까까지만 해도 불완전한 데이터 조각처럼 보였던 { likes: ... }가, 이제는 군더더기 없이 핵심만 담은 명확한 ‘지시 사항’으로 보이기 시작했다. 불필요한 정보는 보내지 않음으로써, 의도치 않은 데이터 오염 가능성을 원천적으로 차단하는 안전장치이기도 했다.
“그럼… PATCH는 ‘부분 수정’을 위한 특별한 업데이트 방식이었구나. 나는 그냥 ‘업데이트’는 다 똑같은 건 줄 알았어. 바꿀 필드만 명시해서 보내는, 일종의 수정 지시서 같은 거네.”
솔라는 고개를 끄덕이며 중얼거렸다. 드디어 PATCH의 정체가 손에 잡히는 듯했다. 서버에 보낼 데이터를 최소화하는 것이 단순히 네트워크 비용을 아끼는 차원을 넘어, 수정 대상을 명확히 지정해 작업의 안전성을 높이는 의미가 있다는 것을 깨달았다.
한 가지 의문이 풀리자, 자신감이 붙은 솔라의 눈이 코드의 다음 줄로 향했다. 하지만 그 자신감은 오래가지 못했다. 새로운 질문이 고개를 들었다.
“좋아, 서버에 바꿀 부분만 딱 알려주는 것까진 알겠어. 그럼 서버는 요청대로 likes 값을 바꾸고 나서, 업데이트가 완료된 게시글 객체 전체를 우리한테 다시 보내주잖아. 그런데 우리는 왜 그걸 받아서 map이란 걸로 기존 게시글 목록 전체를 새로 만드는 복잡한 일을 하는 거지?”
솔라가 화면의 setPosts(posts.map(p => ...)) 부분을 손가락으로 가리켰다.
“그냥 우리 화면(클라이언트)에 있는 데이터에서도 likes 값만 찾아서 1 더해주면 훨씬 간단하고 빠르지 않아? 서버가 잘 바꿨다고 알려줬으니, 우리도 똑같이 숫자만 바꾸면 되는 거 아니야?”
2장: 서버 응답과 불변성: map으로 상태를 업데이트하는 이유
솔라의 질문에 루나는 바로 대답하는 대신, 솔라의 의자를 자기 쪽으로 조금 더 끌어당겼다. 그리고는 자신의 노트북에서 비어 있는 코드 편집기 창을 하나 열었다. 배경은 어두웠고, 커서만 외롭게 깜빡이고 있었다.
루나는 말없이 키보드를 두드려 간단한 데이터 배열을 만들었다.
let originalPosts = [
{ id: 1, content: '첫 게시글!', likes: 14 },
{ id: 2, content: '오늘 날씨 맑음', likes: 5 }
];
“이게 지금 우리 화면에 보이는 게시글 목록이라고 해보자. originalPosts라는 이름의 상자에 담겨 있어.”
루나는 이어서 솔라가 제안한 ‘간단한 방식’을 코드로 옮겼다. 1번 게시글의 ‘좋아요’를 1 올리는 상황이었다.
// 솔라의 방식: 목록에서 해당 게시글을 찾아 직접 수정한다.
let targetPost = originalPosts.find(p => p.id === 1);
if (targetPost) {
targetPost.likes = targetPost.likes + 1; // 'likes' 값을 직접 15로 바꾼다.
}
“자, 이렇게 하면 originalPosts의 첫 번째 게시글 likes는 15가 되겠지. 간단하고 직관적이야.”
루나의 말에 솔라는 “그치? 내 말이.”라며 의기양양하게 고개를 끄덕였다. 굳이 복잡한 map을 쓸 이유가 없어 보였다. 하지만 루나는 이어서 다른 코드 덩어리를 작성하기 시작했다.
“이번엔 우리가 원래 쓰던 map 방식을 써볼게. 서버에서 ‘좋아요’가 15로 업데이트된 새 게시글 객체를 받았다고 가정하자.”
// 서버로부터 받은, 업데이트가 완료된 객체
const updatedPostFromServer = { id: 1, content: '첫 게시글!', likes: 15 };
// map 방식: 새로운 배열을 만든다.
let newPosts = originalPosts.map(p => {
if (p.id === 1) {
return updatedPostFromServer; // 1번 id를 만나면, 서버가 준 새 객체로 교체한다.
} else {
return p; // 나머지는 그대로 둔다.
}
});
코드를 다 작성한 루나는 키보드에서 손을 떼고 솔라를 바라보았다.
“두 방식 모두 결과적으로는 1번 게시글의 likes를 15로 만들었어. 하지만 결정적인 차이가 하나 있어. 화면을 그려주는 관리자 입장에서 생각해 봐.”
루나는 ‘관리자’라는 단어에 힘을 주어 말했다.
“관리자는 수많은 데이터를 다루느라 아주 바빠. 그래서 데이터가 바뀌었는지 확인할 때, 내용물을 하나하나 비교하지 않고 그냥 ‘데이터가 담긴 상자 자체가 바뀌었나?’만 확인해. 그게 훨씬 빠르니까.”
솔라는 루나의 비유를 이해하려 애쓰며 눈을 깜빡였다.
“솔라 네 방식은, originalPosts라는 원래 상자 안에 들어있는 내용물 하나를 살짝 바꾼 거야. 상자 자체는 그대로지? 관리자가 와서 봐도 어제 봤던 그 originalPosts 상자야. 내용물이 바뀐 걸 눈치채지 못하고 그냥 지나칠 가능성이 커.”
솔라는 아까 자신이 짰던 코드를 떠올렸다. targetPost.likes = ... 부분. 분명히 배열 안의 객체 속성을 직접 건드렸다.
“하지만 map 방식은 달라. newPosts라는 완전히 새로운 상자를 만들었어. 그리고 그 안에 업데이트된 내용물을 담았지. 관리자는 originalPosts가 아니라 newPosts라는 생전 처음 보는 상자를 발견하게 돼. 그럼 ‘어? 새 상자네? 내용물이 바뀌었나 보다! 화면을 새로 그려야겠다!’라고 즉시 알아차리는 거야.”
그제야 솔라의 머릿속에서 두 방식의 차이가 선명하게 그려졌다. 첫 번째 방식은 기존 데이터를 ‘수정(mutation)’하는 것이고, 두 번째 방식은 기존 데이터를 절대 건드리지 않고(불변성, immutability), 변경 사항을 적용한 ‘복사본’을 새로 만드는 것이었다. 화면을 업데이트하는 시스템은 바로 이 ‘새로운 복사본’의 등장을 변화의 신호로 감지했다.
“아…! 그러니까 map을 쓰는 건 단순히 값을 바꾸려는 목적이 아니라, 시스템한테 ‘나 바뀌었으니까 주목해!’라고 알려주기 위한 신호 같은 거구나. 기존 데이터를 직접 건드리지 않고 새 데이터를 만들어서 건네주면, 시스템이 변화를 놓치지 않고 화면을 다시 그려주니까.”
솔라는 한 걸음 더 나아갔다.
“그럼 서버에서 updated 객체를 통째로 받아서 쓰는 이유도 알겠어. 내가 ‘likes만 1 더하면 되지’라고 생각한 건 오만한 추측이었네. 내가 ‘좋아요’를 누르는 그 찰나에 다른 사람 수십 명이 동시에 눌렀을 수도 있잖아. 그러면 내 화면의 likes 값과 서버의 실제 값은 달라지겠지. 하지만 서버가 보내준 최종 결과물인 updated 객체로 내 화면의 데이터를 통째로 갈아 끼우면, 그런 데이터 불일치가 일어날 틈이 없는 거구나. 항상 서버가 가진 가장 최신의 정확한 상태를 유지하게 되는 거네.”
자신의 생각이 정리되자 솔라는 들뜬 목소리로 외쳤다. 자신의 간단한 아이디어가 왜 위험한지, 그리고 map과 서버 응답 객체를 사용하는 코드가 왜 그렇게 작성되었는지 완벽하게 이해했다. 그것은 복잡한 게 아니라, 예측 불가능한 상황에서도 데이터를 안전하고 일관되게 유지하기 위한 영리한 장치였다. 이것이 바로 불변적 상태 갱신의 핵심 원리였다.
의문이 풀린 솔라는 후련한 표정으로 화면의 코드를 다시 바라보았다. PATCH로 변경분만 서버에 알리는 것과, map으로 상태를 안전하게 교체하는 것. 두 가지 퍼즐 조각이 손에 쥐어졌다.
“좋아, 이제 두 가지 강력한 기술을 각각 이해했어. ‘부분 수정 명세’랑 ‘불변적 상태 갱신’. 그런데… 이 둘이 handleLike라는 함수 안에서 어떻게 딱 맞물려서 하나의 완벽한 흐름으로 동작하는 거지? 사용자가 버튼을 누르는 순간부터 화면의 숫자가 바뀌기까지, 전체 과정이 머릿속에서 한 번에 그려지질 않아.”
3장: 완전한 ‘좋아요’ 업데이트: 클라이언트-서버 동기화
솔라의 책상 위에는 이제 두 개의 보이지 않는 퍼즐 조각이 놓여 있었다. 하나는 ‘부분 수정 명세’라는 이름의, 서버에 보낼 요청을 날렵하게 만드는 기술. 다른 하나는 ‘불변적 상태 갱신’이라는, 클라이언트의 데이터를 안전하게 지키는 기술이었다. 둘 다 각자의 역할은 명확하게 이해했다. 하지만 이 둘이 어떻게 하나의 정교한 기계처럼 맞물려 돌아가는지는 여전히 안갯속이었다.
그때, 루나가 솔라의 화면에 떠 있던 handleLike 함수 전체를 복사해 자신의 코드 편집기 빈 화면에 붙여 넣었다. 그리고는 함수 코드 옆으로 커다란 사각형 두 개를 그렸다. 왼쪽 사각형 위에는 ‘클라이언트 (내 컴퓨터)’라고 적었고, 오른쪽 사각형 위에는 ‘서버 (데이터 저장소)’라고 적었다.
// 왼쪽 '클라이언트' 박스 옆에 보이는 코드
const handleLike = async (id, currentLikes) => {
// 1. 서버에 보낼 요청 만들기
const response = await fetch(`/posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
likes: currentLikes + 1
})
});
// 2. 서버의 응답 받기
const updatedPost = await response.json();
// 3. 화면 업데이트하기
setPosts(
posts.map(p => p.id === id ? updatedPost : p)
);
};
루나는 펜을 들어 솔라에게 건넸다. 하지만 펜이 아니라 마치 지휘봉을 건네는 것 같았다.
“자, 이제 네가 지휘자야. 사용자가 ‘좋아요’ 버튼을 누르는 순간부터 데이터가 어떤 여정을 떠나는지, 이 그림 위에서 직접 설명해 봐. 1번부터 3번까지, 데이터가 어디에서 어디로, 왜 그렇게 이동해야 하는지.”
솔라는 잠시 망설이다 펜을 받아 들었다. 각 단계가 무슨 일을 하는지는 알았지만, 그것들을 하나의 이야기로 엮어내는 것은 다른 문제였다.
“음… 일단 사용자가 버튼을 누르면 handleLike 함수가 실행돼. 그럼 1번 단계에서… PATCH 요청을 만들어서 서버로 보내. 여기엔 { likes: ... } 이것만 담아서. ‘부분 수정 명세’를 쓰는 거지.”
솔라는 펜으로 클라이언트 박스에서 서버 박스를 향해 화살표를 그렸다. 화살표 위에는 ‘PATCH 요청’이라고 작게 적었다.
“좋아, 그 다음은?”
“서버는 요청을 받고 데이터베이스의 likes 값을 1 올려. 그리고… 2번 단계, 응답을 주네. 업데이트가 끝난 게시글 객체 전체를 클라이언트로 다시 돌려줘.”
솔라는 이번엔 서버 박스에서 클라이언트 박스로 돌아오는 화살표를 그렸다. ‘updatedPost 객체’라고 적었다.
“그리고 마지막 3단계. 클라이언트는 서버가 준 updatedPost를 이용해서 map으로 새로운 게시글 목록을 만들어. ‘불변적 상태 갱신’으로 화면을 안전하게 다시 그리는 거야.”
설명을 마친 솔라는 루나를 쳐다봤다. 틀린 말은 없는 것 같았다. 하지만 어딘가 자신감이 부족했다. 마치 정해진 대본을 읽은 배우처럼, 각본의 의도를 완벽히 꿰뚫지는 못한 표정이었다.
“거의 다 왔어. 그런데 한 가지 중요한 질문이 빠졌네. 왜 꼭 이 순서여야 할까?”
루나의 질문에 솔라는 말문이 막혔다.
“예를 들어, 사용자가 버튼을 누르자마자 일단 클라이언트 화면의 숫자부터 1 올리고, 그 다음에 서버에 ‘나 1 올렸으니까 너도 바꿔’라고 알려주면 안 돼? 그럼 사용자는 훨씬 빠르다고 느낄 텐데.”
솔라의 눈이 반짝였다. 그거였다. 마음속에 남아있던 마지막 찜찜함.
“맞아! 그게 훨씬 효율적이잖아. 일단 화면부터 바꾸고, 백그라운드에서 서버랑 조용히 동기화하면 되지 않아? 왜 굳이 서버의 응답을 기다렸다가 화면을 바꿔야 해?”
루나는 솔라가 그린 그림 위, 클라이언트와 서버를 잇는 화살표를 펜 끝으로 톡톡 건드렸다.
“이 화살표는 인터넷이라는 길이야. 이 길은 언제나 완벽하게 포장되어 있을까? 중간에 길이 끊기거나, 목적지인 서버가 잠시 문을 닫았을 수도 있잖아.”
“아…”
“솔라 네 말대로 화면의 숫자를 15로 먼저 바꿨다고 해보자. 그런데 서버로 가던 요청이 인터넷 문제로 사라졌어. 그럼 어떻게 되지?”
솔라의 머릿속에 상황이 그려졌다.
“내 화면에는 ‘좋아요 15’라고 보이는데, 실제 서버 데이터는 여전히 ‘14’인 상태가 되겠네. 새로고침하면 다시 14로 돌아갈 거고… 사용자 입장에선 ‘좋아요’가 취소된 것처럼 보이겠구나.”
데이터의 불일치. 생각지도 못한 함정이었다. 서버의 응답을 기다리는 시간은 단순히 지연이 아니었다. 그것은 데이터의 정합성을 보장하기 위한 최소한의 안전장치였던 것이다.
그제야 솔라는 자신이 그린 순서도의 의미를 온전히 깨달았다. 그것은 단순히 코드의 실행 순서가 아니었다. 클라이언트와 서버라는, 물리적으로 떨어진 두 공간의 상태를 일치시키기 위한 견고한 약속, 하나의 완벽한 부분 업데이트 워크플로우였다.
- 요청 (부분 명세): 클라이언트는 오직 변경할 내용만 담아 서버에 정중히 요청한다.
- 처리 및 응답 (진실의 원천): 서버는 요청을 처리하고, 유일하게 신뢰할 수 있는 최종 결과를 클라이언트에 돌려준다.
- 반영 (불변적 갱신): 클라이언트는 자신의 추측을 믿지 않고, 오직 서버가 보내준 ‘진실’을 가지고 화면을 안전하게 새로 그린다.
“알겠다. 이제 알겠어.”
솔라는 펜을 다시 쥐고, 자신이 그렸던 순서도를 확신에 찬 눈으로 바라보았다.
“이건 그냥 코드의 나열이 아니었어. 혹시 모를 위험에 대비해서, 항상 서버를 기준으로 상태를 맞추는 안전한 작업 흐름이었네. PATCH와 map은 이 흐름을 만들기 위한 완벽한 한 쌍이었던 거고.”
두 개의 퍼즐 조각이 ‘착’ 하고 맞춰지는 소리가 머릿속에서 들리는 듯했다.
개념을 완전히 이해했다는 자신감이 솔라의 얼굴에 피어올랐다. 루나는 그 모습을 보고 조용히 새 과제를 던졌다.
“좋아. 그럼 이제 이 워크플로우를 써서, 게시글의 ‘공개/비공개’ 상태를 바꾸는 토글 기능을 만든다고 계획해 봐. 코드를 다 짤 필요는 없어. 어떤 데이터를, 어떤 순서로 주고받을지 설계만 해보는 거야.”
솔라는 망설임 없이 빈 종이를 끌어당겼다. 방금 깨달은 워크플로우를 그대로 적용하기 시작했다.
- 사용자 행동: ‘비공개로 전환’ 버튼 클릭.
- 클라이언트의 요청:
PATCH /posts/1요청을 보낸다.body에는{ isPublic: false }만 담는다. - 서버의 응답:
isPublic상태가false로 변경된,id가 1인 게시글 객체 전체를 돌려준다. - 클라이언트의 화면 업데이트: 서버가 준 객체를 사용하여
map으로 기존 게시글 목록을 교체하고, 화면을 새로 그린다.
계획을 다 적은 솔라는 고개를 들었다. ‘좋아요’ 기능에서 배웠던 흐름이, 전혀 다른 기능에도 그대로 적용되는 것을 직접 확인한 순간이었다. 더 이상 PATCH는 낯선 메서드가 아니었고, map을 사용한 상태 업데이트는 복잡한 코드가 아니었다. 그것은 데이터를 안전하게 다루기 위한 믿음직한 도구이자 절차가 되어 있었다.