Frontend 25
배열 state 불변성과 좋아요 업데이트
좋아요 숫자 하나만 바꾸면 되는데 왜 map, 삼항 연산자, 객체 spread까지 써서 새 배열을 만들어야 하는지 모르겠다.
근거 · 교안 p175-p186
1장: React의 눈에는 왜 안 보일까?
솔라의 손가락이 노트북 트랙패드 위에서 멈칫했다. 화면에는 솔라가 막 완성한 작은 소셜 피드 앱이 떠 있었다. 디자인은 제법 그럴듯했다. 프로필 사진, 사용자 이름, 그리고 깔끔한 카드 안에 담긴 게시글. 그 아래엔 하트 아이콘과 ‘좋아요 15개’라는 텍스트가 보였다.
문제는 그 하트 아이콘이었다.
솔라는 다시 한번 하트를 클릭했다. 아무 일도 일어나지 않았다. ‘좋아요 15개’라는 숫자는 요지부동이었다. 하지만 개발자 도구 콘솔 창에는 ‘likes: 16’이라는 로그가 선명하게 찍혔다. 분명히 데이터는 바뀌고 있었다. 함수도 실행됐고, state를 업데이트하라는 setPosts 함수도 호출했다. 그런데 왜 화면은 꼼짝도 안 하는 걸까?
“언니, 이것 좀 봐봐. 이상해.”
거실 반대편에서 책을 읽던 루나가 고개를 들었다. 솔라는 노트북을 들고 루나 옆으로 가 소파에 앉았다.
“코드를 보면, ‘좋아요’ 버튼을 누르면 handleLike 함수가 실행돼. 여기서 첫 번째 게시물의 likes를 1 증가시키고, setPosts로 상태를 업데이트하라고 명령까지 내렸어. 콘솔에도 숫자가 바뀌었다고 나오는데, 화면만 그대로야.”
솔라가 보여준 코드는 직관적이고 명료했다.
const handleLike = (postId) => {
const targetPost = posts.find(p => p.id === postId);
targetPost.likes = targetPost.likes + 1;
setPosts(posts); // 바뀐 posts 배열로 업데이트!
console.log(posts[0].likes); // 16
};
루나는 코드를 잠시 들여다보더니, 말없이 자리에서 일어났다. 그리고는 서랍에서 포스트잇 두 묶음과 펜 두 자루를 가져왔다. 똑같이 생긴 노란색 포스트잇 묶음이었다. 루나는 그중 하나를 솔라 앞에 놓았다.
“이 포스트잇 묶음이 posts라는 이름의 상자라고 생각해봐. 상자 안에는 게시글 데이터가 적힌 종이들이 순서대로 들어 있고.”
루나는 펜으로 포스트잇 첫 장에 ‘게시글 1, 좋아요 15개’라고 적었다.
“자, 이제 솔라 네가 방금 짠 코드처럼 ‘좋아요’를 하나 늘려봐.”
솔라는 루나의 의도를 파악하려는 듯 잠시 생각하다가, 펜을 들었다. 그리고 루나가 건네준 바로 그 포스트잇 묶음의 첫 장에 적힌 ‘15’ 위에 줄을 긋고 ‘16’이라고 고쳐 썼다.
“됐어. 이제 setPosts(posts)를 호출할 차례지? 바뀐 데이터를 리액트에게 전달해줘.”
솔라는 숫자를 고쳐 쓴 포스트잇 묶음을 루나에게 건넸다. “자, 여기.”
루나는 솔라가 건넨 포스트잇 묶음을 받았다. 그리고는 아무것도 하지 않고, 원래 솔라 앞에 놓여있던 바로 그 자리에 포스트잇 묶음을 다시 내려놓았다.
“이게 이전 상태. 그리고 네가 방금 준 이게 새로운 상태.”
루나는 원래 있던 포스트잇 묶음과 솔라가 고쳐서 건네준 포스트잇 묶음을 번갈아 가리켰다. 하지만 그건 결국 똑같은 하나의 묶음이었다.
“나는 리액트야. 이제 두 상태를 비교해볼게.” 루나가 말했다. “어라? 이전 상자랑 새로 받은 상자가 완전히 똑같은 상자네? 주소도 같고, 생긴 것도 같고. 그럼 내용물도 똑같겠지. 굳이 화면을 새로 그릴 필요 없겠다.”
솔라의 눈이 동그래졌다.
“아…!”
짧은 탄성과 함께 모든 조각이 맞춰지는 느낌이었다. 솔라는 자신의 코드를 다시 떠올렸다. targetPost.likes를 바꾼 것은 포스트잇 묶음 안의 종이 한 장에 적힌 숫자를 고친 것과 같았다. 그리고 setPosts(posts)를 호출한 것은, 그 묶음 자체를 그대로 전달한 것이었다. 리액트 입장에서는 ‘이전 posts’와 ‘새로운 posts’가 가리키는 메모리상의 위치, 즉 주소가 완전히 동일했다.
“리액트는 상자 안의 내용물을 일일이 확인하지 않는 거구나.” 솔라가 중얼거렸다.
“효율 때문이지. 수천 개의 게시글이 담긴 배열이 있다고 생각해봐. 상태가 바뀔 때마다 그 안의 모든 내용을 이전 상태와 비교하는 건 엄청난 비용이 드니까. 대신 리액트는 아주 간단한 방법을 쓰는 거야. ‘이전 상태가 담긴 상자’와 ‘새로운 상태가 담긴 상자’가 같은 상자인지, 다른 상자인지만 확인하는 거지.”
루나가 말을 이었다. “이걸 참조 비교라고 불러. 두 변수가 같은 메모리 주소를 가리키고 있는지(참조하는지) 비교하는 거야. 솔라 네 코드는 내용물만 바꿨을 뿐, 완전히 ‘새로운 상자’를 만들어서 전달하지 않았어. 그래서 리액트의 눈에는 아무 변화도 없는 것처럼 보인 거고.”
솔라는 고개를 끄덕였다. 더 이상 ‘왜 안 되지?’라는 막연한 답답함이 아니었다. 명확한 원인이 보였다. 문제는 setPosts를 호출했냐 안 했냐가 아니었다. setPosts에 무엇을 전달했느냐의 문제였다. 원본 배열의 속성값만 살짝 바꾼 것은, 리액트가 변화를 감지하는 방식과 맞지 않았던 것이다.
“그럼… 리액트가 알아채게 하려면, 내용이 아주 조금만 바뀌더라도 완전히 새로운 포스트잇 묶음을 만들어서 줘야 한다는 거네?”
솔라의 머릿속에 새로운 질문이 떠올랐다. 단순히 숫자 하나 바꾸자고 그 큰 배열 전체를 새로 만들어야 한다니, 그건 그거대로 비효율적인 것 아닌가?
“응. 맞아. 항상 새로운 상자를 만들어서 줘야 해.”
루나의 대답은 명쾌했지만, 솔라의 마음속엔 더 근본적인 의문이 자리 잡기 시작했다. 알겠다. 참조가 중요하단 건. 그런데 왜 리액트는 이렇게 까다롭게 구는 걸까? 이 ‘참조 비교’라는 방식이 그렇게나 중요해서, 개발자가 이 모든 복잡함을 감수해야 할 만큼의 가치가 있는 걸까?
2장: 왜 ‘새로’ 만들어야만 할까? (불변성의 진짜 이유)
솔라의 의문은 다음 날 아침, 엉뚱한 형태로 눈앞에 나타났다. 루나는 거실 테이블 위에 어제 썼던 노란색 포스트잇 묶음 옆에, 마치 전화번호부처럼 두꺼운 새 포스트잇 묶음 두 개를 나란히 놓았다. 수백 장은 족히 되어 보였다. 루나는 그중 한 묶음의 중간쯤 어딘가에 펜으로 작은 점 하나를 찍고는, 두 묶음을 다시 섞어 솔라 앞에 내밀었다.
“자, 문제. 이 두 묶음은 완전히 똑같을까, 아니면 어딘가 다를까?”
솔라는 눈살을 찌푸렸다. “이걸 어떻게 알아? 한 장씩 다 넘겨보라고?” 루나는 말없이 옆에 있던 스마트폰의 스톱워치 앱을 켰다. 솔라는 어이가 없다는 듯 헛웃음을 쳤다. 장난이 아니었다.
“언니, 지금 진심이야? 어제는 그냥 상자가 같냐 다르냐만 본다며. 이건 그 상자 안에 뭐가 들었는지 전부 다 뒤져보라는 거잖아. 비효율의 끝판왕인데?”
“바로 그거야.”
루나가 스톱워치를 내려놓으며 말했다.
“만약 리액트가 솔라 네가 처음 생각했던 것처럼, 상자 안의 내용물 변화까지 일일이 확인해야 했다면, 매번 이런 일을 해야만 해. 우리는 이걸 ‘깊은 비교(Deep Comparison)’라고 부를 수 있겠지. 게시글이 수천 개라면? 그 안에 달린 댓글이 수만 개라면? ‘좋아요’ 숫자 하나 바뀌었는지 확인하려고 매번 그 모든 걸 비교하는 건, 방금 네가 말한 그대로 ‘비효율의 끝판왕’이야.”
솔라는 두 개의 두꺼운 포스트잇 묶음을 번갈아 보았다. 어제는 단순히 ‘새 상자’를 주지 않아서 생긴 문제라고 생각했다. 그런데 오늘은 그 ‘새 상자’를 만드는 것이 왜 중요한지에 대한 더 근본적인 이유가 보이기 시작했다. 리액트가 참조 비교라는 단순한 방법을 쓰는 이유는, 게으르기 때문이 아니라 영리하기 때문이었다.
“그러니까… 리액트는 비용이 많이 드는 ‘깊은 비교’를 피하기 위해서, 개발자에게 한 가지 규칙을 요구하는 거구나. ‘데이터를 바꾸고 싶으면, 원본은 절대 건드리지 말고, 항상 새로운 복사본을 만들어서 알려줘!’ 라고.”
“정확해. 우리는 그 규칙을 불변성(Immutability) 원칙이라고 불러. ‘변하지 않는 성질’이라는 뜻이지.”
루나는 테이블 위에 있던 깨끗한 A4 용지 한 장을 가져와 중앙에 ‘불변성’이라고 적었다.
“이 원칙이 왜 그렇게 중요할까? 속도 때문만은 아니야. 다른 큰 이점도 있어.”
루나는 포스트잇 묶음 하나를 집어 들었다. “이게 원본 데이터라고 해보자. 만약 모두가 이 원본을 직접 수정할 수 있다면 무슨 일이 벌어질까? A라는 기능은 ‘좋아요’를 바꾸고, B라는 기능은 제목을 바꾸고, C라는 기능은 갑자기 게시글을 지워버려. 모든 게 하나의 원본 위에서 뒤엉키는 거지.”
솔라는 상상만 해도 머리가 아파왔다. 어디서 버그가 발생했는지 추적하는 것이 거의 불가능할 것 같았다.
“하지만 불변성 원칙을 지키면, 즉 원본을 수정하는 대신 항상 새로운 복사본을 만들면 어떻게 될까?”
루나는 말을 이었다. “A 기능은 ‘좋아요’가 바뀐 새 복사본(A-1)을 만들고, B 기능은 원본을 바탕으로 제목이 바뀐 새 복사본(B-1)을 만들어. 원본은 언제나 처음 상태 그대로 안전하게 보존돼. 상태의 모든 변화는 ‘새로운 버전’이 생기는 걸로 기록되지. 마치 게임 저장 파일처럼. 버그가 생기면, 어떤 버전에서 문제가 생겼는지 시간 순서대로 되돌아가며 확인하면 돼. 훨씬 예측 가능하고 안정적이지.”
이제야 모든 것이 명확해졌다. ‘항상 새 배열과 새 객체를 만들어야 한다’는 규칙은 비효율적이거나 복잡하기만 한 제약이 아니었다. 그것은 리액트가 빠르고, 예측 가능하며, 디버깅하기 쉬운 애플리케이션을 만들 수 있게 하는 핵심적인 약속이자 원칙이었다. 참조 비교는 그 약속을 가장 효율적으로 확인하는 수단일 뿐이었다.
솔라는 고개를 끄덕였다. 더 이상 불만이 아니었다. 수긍이었다. 원본을 직접 바꾸는 것은 쉽고 편해 보이지만, 앱 전체를 예측 불가능한 혼돈으로 몰아넣을 수 있는 위험한 지름길이었다. 반면, 새로운 복사본을 만드는 것은 약간의 수고가 더 들지만, 변화를 명확하게 추적하고 시스템 전체의 안정성을 보장하는 길이었다.
“알겠어. 이제 왜 그래야만 하는지는 확실히 이해했어. 불변성… 원본은 신성불가침으로 두고, 바뀔 때마다 새로운 버전을 만드는 거구나.”
솔라의 머릿속이 정리되자, 곧바로 다음 단계의 질문이 떠올랐다. 이전의 답답함과는 다른, 명확한 목표를 가진 질문이었다.
“좋아, 원칙은 납득했어. 그런데… 실제로 코드를 짤 때가 문제야. 게시글이 100개인 배열에서 딱 하나만 바꾸고 싶을 때, 나머지 99개를 일일이 손으로 복사해서 새 배열을 만드는 건 아니잖아? 분명 더 똑똑한 방법이 있겠지? 언니가 말한 map이나 spread 같은 게 혹시 그런 건가?”
3장: 배열의 변신 (map으로 새 배열 만들기)
솔라는 노트북 앞에 앉아 빈 코드 편집기를 멍하니 바라봤다. ‘불변성… 원본은 건드리지 않고, 항상 새로운 복사본을 만든다.’ 원칙은 머릿속에 선명했다. 하지만 막상 코드로 옮기려니 손가락이 움직이지 않았다. 게시글 백 개가 담긴 posts 배열에서, ID가 3인 게시물의 좋아요만 하나 늘리고 싶다. 그러려면 ‘새로운’ posts 배열을 만들어야 한다.
솔라는 가장 정직한 방법을 떠올렸다. 일단 빈 배열을 하나 만들고, 원래 posts 배열을 처음부터 끝까지 훑으면서 하나씩 새 배열에 집어넣는다. 그러다 ID가 3인 녀석을 만나면, 그놈만 ‘좋아요’를 1 늘린 새 객체로 만들어서 넣고, 나머지는 그대로 복사한다.
생각을 코드로 옮겨보았다.
// 솔라의 생각...
const handleLike = (postId) => {
const newPosts = []; // 1. 새로운 빈 배열을 만든다.
for (let i = 0; i < posts.length; i++) { // 2. 기존 배열을 순회한다.
const post = posts[i];
if (post.id === postId) { // 3. ID가 일치하는 항목을 찾으면
// '좋아요'가 업데이트된 새 객체를 만들어서...
const updatedPost = {
id: post.id,
author: post.author,
content: post.content,
likes: post.likes + 1,
// ... 만약 속성이 20개라면? 으...
};
newPosts.push(updatedPost); // 4. 새 배열에 넣는다.
} else {
newPosts.push(post); // 5. 나머지는 그대로 새 배열에 넣는다.
}
}
setPosts(newPosts); // 6. 마침내 완성된 새 배열로 상태를 업데이트한다!
};
코드를 다 적고 나니 한숨이 나왔다. 돌아는 가겠지만, 너무 길고 볼품없었다. 특히 updatedPost를 만드는 부분이 마음에 걸렸다. 게시글 객체에 속성이 더 많아진다면 이 코드는 금방 지저분해질 터였다. 게다가 고작 항목 하나 바꾸자고 매번 이 긴 코드를 작성해야 한다니.
그때 거실을 지나가던 루나가 솔라의 모니터를 힐끗 보더니, 잠시 멈춰 섰다. 그리고는 아무 말 없이 주방으로 가더니, 하얀색 접시 두 개와 접시 위에 놓인 딸기 다섯 개를 가져왔다. 루나는 딸기 다섯 개가 담긴 접시를 솔라 앞에 놓았다.
“이게 원본 posts 배열이야. 딸기 하나하나가 게시물 객체고.”
루나는 빈 접시를 그 옆에 두었다.
“이제 저 빈 접시에, 네가 원하는 ‘새로운 상태’를 만들어봐. 규칙은 딱 하나야. 저기 세 번째 딸기만 꼭지를 뗀 상태로 옮겨 담아야 해.”
솔라는 잠시 루나를 쳐다보다가 피식 웃으며 손을 뻗었다. 첫 번째 딸기를 집어 빈 접시에 옮겼다. 두 번째 딸기도 옮겼다. 세 번째 딸기를 집어서는 꼭지를 떼어내고 빈 접시에 놓았다. 그리고 네 번째와 다섯 번째 딸기는 그대로 옮겨 담았다.
“됐어.”
“방금 네가 한 작업을 말로 설명해볼래?” 루나가 물었다.
“원본 접시에 있는 딸기들을 하나씩 보면서, 첫 번째랑 두 번째는 그대로 새 접시에 옮겼고, 세 번째는 꼭지를 떼는 변환을 해서 옮겼고, 나머지도 그대로 옮겼어.”
“바로 그거야.” 루나가 말했다. “기존 배열의 모든 항목을 처음부터 끝까지 하나씩 순회하면서, 특정 조건에 따라 항목을 변환하거나 그대로 유지해서, 완전히 새로운 배열을 만드는 작업. 자바스크립트에는 정확히 그 일을 하는 내장 함수가 있어. 바로 map이야.”
솔라의 눈이 커졌다. map. 들어본 적은 있었지만, 그저 forEach처럼 반복문 역할을 하는 함수라고만 어렴풋이 알고 있었다.
“map은 forEach랑은 목적이 달라. map의 유일한 존재 이유는 ‘새로운 배열을 생성해서 반환하는 것’이야. 원본 배열을 절대 바꾸지 않으면서, 원본과 똑같은 길이의 새로운 배열을 만들어내지. 방금 네가 딸기를 옮긴 것처럼.”
루나는 솔라의 노트북을 가리켰다. “솔라 네가 for문으로 길게 썼던 코드가 바로 map이 하는 일을 설명한 거였어.”
루나의 설명에 따라 솔라는 자신의 코드를 map을 사용해 다시 작성하기 시작했다.
const handleLike = (postId) => {
const newPosts = posts.map((post) => {
if (post.id === postId) {
// 바꿀 녀석. 변환해서 반환!
return {
id: post.id,
author: post.author,
content: post.content,
likes: post.likes + 1,
};
} else {
// 나머지는 그대로 반환.
return post;
}
});
setPosts(newPosts);
};
코드가 거짓말처럼 짧고 명확해졌다. map 함수가 for문, 빈 배열 선언, push 로직을 모두 대신하고 있었다. map에 전달된 함수는 각 post를 어떻게 변환해서 새 배열에 넣을지에 대한 ‘설명서’ 역할을 했다. 조건에 맞으면 변환된 새 객체를 반환하고, 아니면 원래 post를 그대로 반환하면, map이 알아서 새 배열을 차곡차곡 만들어주는 것이었다.
“와… map은 그냥 반복문이 아니라 ‘배열 변환 공장’이었구나.”
솔라는 감탄했지만, 이내 한 가지 의문이 고개를 들었다. “그런데 언니, 이것도 결국 게시물이 100개면 100번 다 확인하는 거잖아. 비효율적인 건 마찬가지 아니야?”
“미세하게 보면 그렇지. 하지만 우리가 얻는 이득에 비하면 사소한 비용이야. 리액트가 ‘깊은 비교’를 하는 것보다 훨씬 빠르고, 무엇보다 코드가 ‘무엇을 하는지’ 명확하게 드러나잖아. ‘이 배열을 바탕으로 변환해서 새 배열을 만든다.’ 이게 map의 이름에 담긴 뜻이니까. 이 명확함이 버그를 막고 코드를 예측 가능하게 만들어주는 거야.”
솔라는 고개를 끄덕였다. map은 각 요소를 순회하며 새로운 배열로 변환하는 것. 그 본질을 이해하자, 비효율적이라는 생각은 사라지고 오히려 불변성을 지키기 위한 가장 우아하고 안정적인 방법이라는 확신이 들었다.
이제 배열을 통째로 복사하는 방법은 알았다. 하지만 여전히 한 부분이 마음에 걸렸다. map으로 바꿀 객체를 찾았을 때, 그 객체까지 새로 만들어야 하는 건 여전했다.
“알겠어. map으로 새 배열의 틀을 만드는 건 완벽히 이해했어. 그런데 이 부분, 바꿀 객체를 새로 만드는 건 아직도 너무 번거로워. 속성이 스무 개면 열아홉 개를 다 일일이 복사해야 하잖아. 혹시… 이 객체 안에서 ‘좋아요’만 쏙 바꿔치기하는 더 똑똑한 방법도 있는 거야?”
4장: ‘좋아요’만 쏙! (객체 spread로 부분 업데이트)
솔라는 자신의 코드 앞에서 미간을 찌푸렸다. map을 써서 코드가 훨씬 나아진 것은 사실이었다. 하지만 map 함수 안의 if 블록, 특히 바꿀 객체를 새로 만드는 부분이 계속 눈에 거슬렸다.
// ...
if (post.id === postId) {
// 바꿀 녀석. 변환해서 반환!
return {
id: post.id,
author: post.author,
content: post.content,
likes: post.likes + 1,
};
} else {
// ...
‘속성이 네 개니까 망정이지, 스무 개라면 어떡하지? 프로필 이미지, 태그 목록, 작성 시간, 수정 시간… 그런 게 다 붙는다면? 좋아요 하나 바꾸자고 나머지 열아홉 개를 전부 손으로 복사해서 붙여넣어야 한다고?’
오기가 생긴 솔라는 상상 속의 속성 몇 개를 실제 post 객체에 추가해봤다.
// posts 초기 상태에 속성 추가...
{
id: 1,
author: '솔라',
content: '오늘 날씨 좋다!',
likes: 15,
tags: ['일상', '날씨'], // 추가!
createdAt: '2023-10-27T10:00:00Z' // 추가!
}
그러자 handleLike 함수는 즉시 더 끔찍한 모습으로 변했다. 새로 추가한 tags와 createdAt을 일일이 복사하는 코드를 넣어야만 했다. 코드가 길어질수록 실수할 가능성도 커졌다. 이건 똑똑한 방법이 아니었다. 분명히 더 나은 길이 있을 터였다.
그때, 솔라의 중얼거림을 들은 루나가 다가왔다. 루나는 솔라의 화면에 가득한, 반복되는 코드 라인들을 잠시 보더니, 책상 서랍에서 투명한 플라스틱 파일 홀더 한 개와 A4 용지 한 장을 꺼내왔다.
루나는 A4 용지에 원래 게시글 객체의 속성들을 적었다.
원본 Post (A4 용지)
- id: 1
- author: ‘솔라’
- content: ‘오늘 날씨 좋다!’
- likes: 15
- tags: [‘일상’, ‘날씨’]
- createdAt: ‘2023-10-27T10:00:00Z’
“이게 원본 post 객체야.”
그러고는 투명한 파일 홀더를 솔라에게 건넸다.
“자, 여기엔 우리가 바꾸고 싶은 것만 적어봐. 나머지 속성은 적지 말고.”
솔라는 잠시 의아했지만, 루나의 의도를 따르기로 했다. 유성 마커로 투명 홀더 위에 딱 한 줄만 적었다.
변경 사항 (투명 홀더)
- likes: 16
루나는 솔라가 적은 투명 홀더를 받아, 원래 속성들이 적힌 A4 용지 위에 정확히 겹쳐 올렸다.
“자, 이제 뭐가 보이지?”
솔라의 눈앞에는 놀라운 광경이 펼쳐졌다. id, author, content 같은 원래 속성들은 투명한 홀더를 통해 그대로 보였다. 하지만 likes 값은 A4 용지에 적힌 ‘15’가 아니라, 투명 홀더 위에 쓴 ‘16’으로 보였다. 마치 덧씌워진 것처럼.
“아…!”
솔라는 무릎을 쳤다.
“이거구나! 원본 종이는 그대로 두고, 바뀐 내용만 담은 투명지를 위에 얹어서 새로운 결과물을 만드는 거네!”
루나는 고개를 끄덕이며 말했다. “자바스크립트에도 정확히 이 ‘겹쳐 올리기’를 하는 문법이 있어. 바로 객체 스프레드(Object Spread) 문법이야. 점 세 개(...)로 표현하지.”
루나는 솔라의 코드를 가리켰다.
“{ ...post } 라고 쓰면, A4 용지, 즉 post 객체의 모든 속성을 그대로 펼쳐놓으라는 뜻이야. 그리고 그 뒤에 우리가 바꾸고 싶은 값을 덧붙이는 거지. { ...post, likes: post.likes + 1 } 이렇게.”
솔라는 홀린 듯이 자신의 코드를 수정하기 시작했다. 길고 지저분했던 객체 생성 부분이 단 한 줄로 바뀌었다.
// 수정 전
return {
id: post.id,
author: post.author,
content: post.content,
likes: post.likes + 1,
tags: post.tags,
createdAt: post.createdAt,
};
// 수정 후
return { ...post, likes: post.likes + 1 };
마법 같았다. ...post가 ‘원본의 모든 속성’을 의미하고, 그 뒤에 오는 likes: post.likes + 1이 ‘투명 홀더’ 역할을 해서 기존 likes 값을 덮어쓰는 것이었다. 속성이 스무 개든 서른 개든, 이제 상관없었다.
“코드가 훨씬 우아해졌어. if-else 구문도 한 줄로 줄일 수 있을 것 같은데?”
자신감이 붙은 솔라는 한 걸음 더 나아갔다. map 안에서 ‘조건이 참이면 A를 반환하고, 거짓이면 B를 반환하는’ if-else 구조는 삼항 연산자로 바꾸는 게 제격이었다.
솔라는 망설임 없이 전체 handleLike 함수를 다시 작성했다.
const handleLike = (postId) => {
const newPosts = posts.map((post) =>
post.id === postId // 만약 ID가 일치하면
? { ...post, likes: post.likes + 1 } // '좋아요'가 업데이트된 새 객체를 반환
: post // 그렇지 않으면 원래 객체를 그대로 반환
);
setPosts(newPosts);
};
완벽했다. map으로 새로운 배열의 판을 깔고, 삼항 연산자로 변경할 객체를 콕 집어낸 다음, 객체 스프레드로 그 객체의 속성만 우아하게 덮어쓴다. 불변성을 지키기 위한 세 가지 기술이 아름다운 협주를 이루는 것 같았다. 원본 배열도, 원본 객체도 전혀 건드리지 않고 완벽하게 새로운 상태를 만들어냈다.
솔라는 방금 완성한 코드를 보며 만족스러운 미소를 지었다. 이제 배열 안의 특정 객체를 ‘수정’하는 작업에는 완벽히 통달한 기분이었다. 그러자 자연스럽게 다른 그림이 그려지기 시작했다.
“좋아, ‘수정(Update)’은 이제 알겠어. 그럼 게시글을 새로 ‘추가(Create)’하거나, 기존 게시글을 ‘삭제(Delete)’할 때도 이렇게 불변성을 지키면서 우아하게 처리하는 패턴이 따로 있을까?”
5장: 불변성 3대 패턴 총정리 (CRUD in Immutability)
솔라의 코드 편집기는 세 개의 기둥처럼 함수 세 개를 나란히 보여주고 있었다. 왼쪽에는 어제 완성한 handleLike 함수가 자랑스럽게 빛나고 있었다. map, 삼항 연산자, 객체 스프레드의 완벽한 조화. 그것은 ‘수정(Update)’을 위한 정답이었다. 그 만족감에 힘입어, 솔라는 ‘추가(Create)’와 ‘삭제(Delete)’ 함수도 만들어보기로 했다.
가운데에는 새로 작성한 addPost 함수가 있었다. 새 게시글 객체를 받아 기존 posts 배열 앞에 추가하는, 비교적 간단한 코드였다.
// 추가 (Create)
const addPost = (newPost) => {
setPosts([newPost, ...posts]);
};
문제는 가장 오른쪽에 있는 deletePost 함수였다. 인터넷을 검색해 보니 filter 라는 메서드를 쓰면 된다는 것을 알아냈다. 코드는 간결했다.
// 삭제 (Delete)
const deletePost = (postId) => {
const newPosts = posts.filter(p => p.id !== postId);
setPosts(newPosts);
};
모두 정상적으로 작동했다. 게시글은 잘 추가되고, 잘 삭제되고, 좋아요도 잘 수정되었다. 그런데 솔라는 어쩐지 마음이 개운치 않았다. 세 개의 함수를 나란히 띄워놓고 보니, 이질감이 느껴졌다.
‘추가할 땐 배열 스프레드([...]), 삭제할 땐 filter, 수정할 땐 map…’
솔라의 미간에 옅은 주름이 잡혔다. 마치 서로 다른 나라의 언어 세 개를 배우는 기분이었다. 불변성을 지켜야 한다는 대원칙 아래 모였지만, 각자 노는 방식이 너무 달라 보였다. 그냥 ‘상황별로 이럴 땐 이거 써’라고 외워야 하는 걸까? 이 세 가지 패턴 사이에 어떤 근본적인 연결고리는 없는 걸까?
“언니, 이것 좀 봐봐. 돌아가긴 하는데, 뭔가 찝찝해.”
솔라의 부름에 루나가 다가와 나란히 놓인 세 개의 함수를 들여다보았다.
“추가는 스프레드로, 삭제는 필터로, 수정은 맵으로. 다 따로 노는 것 같아. 결국 그냥 암기해야 하는 건가 싶어서.”
루나는 솔라의 화면을 잠시 응시하더니, 아무 말 없이 키보드에 손을 올렸다. 그리고는 세 함수에서 공통된 부분, 즉 setPosts(...)에 전달되는 인자([newPost, ...posts], newPosts 등)에 각각 하이라이트를 주었다.
“이 세 함수가 궁극적으로 하려는 일이 뭐야?”
“어… 추가, 삭제, 수정?” 솔라가 멋쩍게 대답했다.
“그건 각자의 역할이고. 그 역할을 수행하기 위해, 세 함수가 공통적으로 만들어내는 결과물이 뭐지?”
루나의 질문에 솔라는 하이라이트된 부분들을 다시 꼼꼼히 뜯어보았다.
addPost는 [newPost, ...posts]를 만들었다. 이건 명백히 새로운 배열이다.
deletePost의 posts.filter(...)는? filter 메서드는 조건에 맞는 요소만 모아 ‘새로운 배열’을 반환한다고 배웠다.
handleLike의 posts.map(...) 역시, 각 요소를 변환하여 ‘새로운 배열’을 반환하는 것이 본질이었다.
그 순간, 흩어져 있던 점들이 하나의 선으로 이어지는 것을 느꼈다.
“아…! 전부… 새로운 배열을 만들고 있어.”
솔라의 목소리에 깨달음이 묻어났다. 그녀가 놓치고 있던 것은 바로 이것이었다. 각기 다른 기술, 즉 spread, filter, map은 그저 목적지로 가는 다른 경로일 뿐이었다. 그리고 그 목적지는 한결같이 ‘새로운 배열 만들기’였다.
- 추가(Create): 기존 배열에 새 항목을 더한 새 배열이 필요해? 그럼 기존 배열을 펼치고(
...posts) 새 항목(newPost)을 앞에 놓으면 가장 간단하겠네. - 삭제(Delete): 기존 배열에서 특정 항목만 빠진 새 배열이 필요해? 그럼
filter로 원하는 조건의 항목만 걸러내서 새 배열을 만들면 완벽하겠네. - 수정(Update): 기존 배열에서 특정 항목 하나만 바뀐 새 배열이 필요해? 그럼
map으로 전체를 복사하되, 원하는 항목만 새 객체로 바꿔치기하면 가장 효율적이겠네.
“맞아.” 루나가 조용히 고개를 끄덕였다. “각자의 행동(local behavior)은 달라 보이지만, 불변성을 지킨다는 정체성(identity)은 똑같아. 리액트에게 ‘이전과는 다른 새로운 참조값을 가진 배열’을 건네준다는 핵심 원칙을 지키기 위해, 상황에 가장 적합한 도구를 선택하는 것뿐이야.”
더 이상 세 개의 패턴은 제각각 노는 규칙의 나열이 아니었다. 그것은 ‘불변성’이라는 하나의 뿌리에서 뻗어 나온, 논리적이고 우아한 세 개의 가지였다. 솔라는 각 함수가 왜 그 특정 메서드를 선택했는지 이제 명확하게 설명할 수 있었다.
솔라는 이 깨달음을 잊고 싶지 않았다. 그녀는 자신의 프로젝트 폴더에 patterns.md라는 새 파일을 만들었다. 그리고 망설임 없이 키보드를 두드리기 시작했다. 이건 누군가 시켜서 하는 과제나, 인터넷에서 복사해서 붙여넣는 코드가 아니었다. 온전히 자신의 머리와 손으로 얻어낸, 살아있는 지식이었다.
React 배열 상태: 불변성 3대 패턴
React에서 배열 state를 다룰 때, 원본을 직접 수정하지 않고 항상 새로운 배열을 생성하여 setState에 전달해야 한다. 이를 위한 핵심 패턴 3가지.
1. 추가 (Create): Array Spread
// 기존 posts 배열의 모든 요소를 복사하고, 맨 앞에 newPost를 추가한 새 배열 생성
const addPost = (newPost) => {
setPosts([newPost, ...posts]);
};
2. 삭제 (Delete): filter()
// filter는 원본을 바꾸지 않고, 조건에 맞는 요소만 담은 '새 배열'을 반환한다.
const deletePost = (postId) => {
setPosts(posts.filter(p => p.id !== postId));
};
3. 수정 (Update): map() + Object Spread
// map으로 모든 요소를 순회하며 '새 배열'을 만든다.
// 수정할 대상을 찾으면, 객체 스프레드로 기존 속성을 복사하고 원하는 값만 덮어쓴 '새 객체'를 반환한다.
const handleLike = (postId) => {
setPosts(
posts.map(p =>
p.id === postId ? { ...p, likes: p.likes + 1 } : p
)
);
};
자신이 정리한 세 개의 코드 블록을 보며 솔라는 만족스럽게 미소 지었다. 이제 어떤 종류의 배열 업데이트 요구사항이 닥쳐도, 당황하지 않고 가장 적절한 패턴을 꺼내 쓸 수 있을 것 같았다. ‘좋아요’ 버튼에서 시작된 작은 의문은, 이제 솔라의 개발 무기고에 가장 든든한 연장 세트가 되어 자리 잡았다.