Frontend 13

React 개발자를 위한 자바스크립트 불변성 패턴

for문도 있는데 왜 forEach, map, filter를 배우고, 구조 분해와 spread가 React에서 왜 자주 나오는지 모르겠다.

근거 · 교안 p54-p60

React 개발자를 위한 자바스크립트 불변성 패턴 대표 이미지

1장: for문 너머: forEach, map, filter의 다른 매력

솔라의 모니터 화면에는 익숙한 코드가 떠 있었다. 게시판의 공지사항 목록에서 ‘필독’ 표시가 된 항목만 따로 모아 화면에 보여주는 기능이었다. 솔라는 능숙하게 for 반복문으로 코드를 작성했다. 원본 데이터 배열을 처음부터 끝까지 훑고, if 조건문으로 ‘필독’ 항목인지 확인한 뒤, 맞으면 새로 만든 빈 배열에 push하는, 군더더기 없는 방식이었다. 기능은 완벽하게 작동했다.

하지만 코드 리뷰를 위해 참고하던 기술 블로그의 한 문장이 자꾸 눈에 걸렸다.

“React 개발에서는 for문보다 forEach, map, filter 같은 배열 메서드를 사용하는 것이 좋습니다. 코드의 의도가 명확해지고, 불변성을 지키기에도 유리하기 때문입니다.”

“의도가 명확해진다고?” 솔라는 중얼거리며 자신의 코드를 다시 쳐다봤다. for (let i = 0; i < notices.length; i++) { ... } 이보다 더 명확한 게 있나? ‘0부터 길이만큼 하나씩 반복한다.’ 누가 봐도 알 수 있는 사실이었다. 솔라는 고개를 갸웃하며 거실에 있는 언니, 루나에게 노크하듯 말했다.

“언니, 잠깐만. 내가 짠 코드 좀 봐줄래?”

루나가 솔라의 의자 뒤로 다가와 화면을 들여다보았다.

“음, ‘필독’ 공지만 거르는 코드구나. 잘 돌아가?”

“응. 완벽하게. 그런데 다들 for문 말고 map이나 filter 같은 걸 쓰래. 이게 더 좋다고. 솔직히 잘 모르겠어. 그냥 문법만 다르고 결국 하는 일은 똑같은 거 아니야? 오히려 for문이 더 직관적인 것 같은데.”

솔라의 말에는 약간의 불만과 진짜 궁금함이 섞여 있었다. 마치 늘 쓰던 편한 망치가 있는데, 굳이 모양만 조금 다른 새 망치를 쓰라는 권유를 받은 기분이었다.

루나는 잠시 생각하더니, 솔라의 책상 위에 놓여 있던 메모지와 펜을 집어 들었다.

“새로운 망치를 써보자는 게 아니라, ‘못질’ 말고 다른 작업에 맞는 도구를 써보자는 거에 가까울 거야. 우리 간단한 작업으로 한번 비교해 볼까?”

루나는 메모지에 숫자 배열 하나를 적었다.

[ 1, 2, 3, 4, 5, 6 ]

“자, 여기서 세 가지 다른 작업을 해보자. 첫째는 그냥 이 숫자들을 하나씩 출력하는 것. 둘째는 각 숫자에 10을 곱한 새 숫자 목록을 만드는 것. 셋째는 3보다 큰 숫자들만 골라내는 것. 이 세 가지를 솔라 네가 익숙한 for문으로 먼저 짜볼래?”

솔라는 고개를 끄덕이고 금세 코드를 써 내려갔다. for문은 솔라에게 가장 익숙한 도구였으니까.

const numbers = [1, 2, 3, 4, 5, 6];

// 작업 1: 모든 숫자 출력하기
const newArray1 = [];
for (let i = 0; i < numbers.length; i++) {
  console.log(numbers[i]);
}

// 작업 2: 모든 숫자에 10 곱하기
const newArray2 = [];
for (let i = 0; i < numbers.length; i++) {
  newArray2.push(numbers[i] * 10);
}

// 작업 3: 3보다 큰 숫자만 고르기
const newArray3 = [];
for (let i = 0; i < numbers.length; i++) {
  if (numbers[i] > 3) {
    newArray3.push(numbers[i]);
  }
}

“다 됐어.”

“좋아. 그럼 이제 이걸 forEach, map, filter로 바꿔보자. 작업 1은 forEach로.”

솔라는 잠시 문법을 떠올리더니 코드를 수정했다.

// 작업 1: forEach 사용
numbers.forEach(number => {
  console.log(number);
});

“오, 이건 확실히 짧네. i도 없고, numbers[i] 같은 것도 없고.”

“그럼 작업 2는 map으로 해볼까? map은 ‘지도’라는 뜻도 있지만 ‘대응시키다’, ‘바꾸다’라는 뜻도 있어. 배열의 각 요소를 새로운 요소로 바꿔서 새 배열을 만들어줘.”

솔라는 map을 사용해 두 번째 작업을 다시 코딩했다.

// 작업 2: map 사용
const mappedArray = numbers.map(number => number * 10);

“어? 이건 push도 필요 없네. 그냥 map이 알아서 새 배열을 만들어 주는구나.” 솔라의 눈이 조금 커졌다. for문에서는 필수였던 ‘빈 배열 선언’과 ‘결과 push’ 과정이 사라졌다.

“마지막으로 작업 3. filter를 써보자. 이름 그대로 ‘거름망’처럼 조건에 맞는 것만 남겨줘.”

// 작업 3: filter 사용
const filteredArray = numbers.filter(number => number > 3);

코드는 믿을 수 없을 만큼 간결해졌다. 세 줄짜리 for문과 if문이 단 한 줄로 바뀌었다. 루나는 나란히 놓인 두 벌의 코드를 조용히 가리켰다.

솔라는 잠시 아무 말 없이 양쪽 코드를 번갈아 보았다. 처음에는 그저 ‘코드가 짧아졌네’ 정도의 감상이었다. 하지만 찬찬히 뜯어볼수록 다른 점이 보이기 시작했다.

for문으로 작성된 세 개의 코드는 구조가 거의 똑같았다. for (let i = 0; ...) 로 시작해서 중괄호 {} 안에 다른 로직이 들어있을 뿐이었다. 이 코드가 정확히 무슨 일을 하는지 알려면, 중괄호 안을 빠짐없이 읽어야만 했다. 그냥 ‘반복’이라는 사실 외에는 아무런 정보도 주지 않았다.

하지만 forEach, map, filter를 쓴 코드는 달랐다.

forEach는 ‘각각에 대해 뭔가를 실행하겠구나’라는 의도를, map은 ‘기존 요소를 변형해서 새로운 배열을 만들겠구나’라는 의도를, filter는 ‘조건에 맞는 요소만 걸러내겠구나’라는 의도를 이름 자체로 드러내고 있었다. 중괄호 안의 구체적인 내용을 읽기 전에 이미 코드의 목적을 짐작할 수 있었다.

“아…”

솔라의 입에서 나지막한 탄성이 터져 나왔다.

“이제 알겠다. for문은 그냥 ‘반복할게’라는 망치 같은 도구라면, 얘네들은 ‘출력용 반복’, ‘변환용 반복’, ‘필터링용 반복’처럼 목적이 정해진 전용 공구 같은 거구나. 코드를 읽는 사람이 ‘그래서 이 반복문으로 뭘 하려는 거지?’ 하고 고민할 필요가 없네.”

솔라는 자신이 처음 작성했던 ‘필독’ 공지사항 코드를 떠올렸다. forif를 썼던 그 코드는 filter 한 줄로 바꿀 수 있었다. ‘아, 저 코드는 목록에서 뭔가를 걸러내는 코드였구나’ 하고 즉시 이해할 수 있게 되는 것이다.

솔라는 만족스럽게 고개를 끄덕였다. 더 이상 map이나 filterfor문의 멋 부린 버전으로 보이지 않았다. 각자의 역할이 뚜렷한, 의미 있는 도구들이었다. 그러다 문득 mapfilter의 공통점이 눈에 들어왔다.

“언니, 그런데 map이랑 filter는 둘 다 원래 있던 numbers 배열은 그대로 두고, 완전히 새로운 배열을 만들어서 돌려주네. for문 쓸 때도 일부러 새 배열 만들어서 push하긴 했는데… 굳이 이렇게 원본은 놔두고 새 걸 만들어야 하는 특별한 이유가 있어? 그냥 원본을 직접 바꾸는 게 더 편할 수도 있잖아?”

편한 망치와 전용 공구의 차이를 납득하자, 이제는 그 전용 공구들이 가진 ‘원본을 훼손하지 않는다’는 공통된 특징이 새로운 질문으로 떠올랐다.

2장: 왜 ‘새’ 배열이어야 할까? 불변성의 중요성

솔라의 질문에 루나는 대답 대신 솔라의 코드 에디터를 가리켰다. 방금 전까지 map, filter 예제로 가득했던 화면을 지우고, 새로운 파일을 열었다. 그리고는 마치 두 개의 레시피를 나란히 제시하듯, 두 가지 함수의 뼈대를 작성했다.

// A안: 원본 데이터를 직접 수정하는 함수
function applyDiscount_A(items) {
  // 여기에 코드 작성
}

// B안: 새로운 데이터를 만들어 반환하는 함수
function applyDiscount_B(items) {
  // 여기에 코드 작성
}

“네 질문에 대한 답을 코드로 직접 찾아보자.” 루나가 말했다. “상황을 하나 가정해 볼게. 우리가 쇼핑몰 앱을 만들고 있고, 장바구니에 담긴 상품 목록이 있어. 여기서 VIP 고객을 위해, 2만원이 넘는 상품에만 10% 할인을 적용하는 기능을 만들려고 해. A안과 B안, 두 가지 방식으로 모두 구현해 줄래?”

솔라는 고개를 끄덕였다. ‘원본을 직접 바꾸는 게 더 편하지 않나?’ 라는 생각이 머릿속에 남아있던 터라, A안이 더 끌렸다. 그녀는 익숙하게 A안부터 작성하기 시작했다. 배열을 순회하며 조건에 맞는 상품 객체에 discountPrice 속성을 그냥 추가해버리면 될 일이었다.

// A안: 원본 데이터를 직접 수정하는 함수
function applyDiscount_A(items) {
  items.forEach(item => {
    if (item.price > 20000) {
      item.discountPrice = item.price * 0.9;
    }
  });
  return items;
}

반면 B안은 조금 더 손이 갔다. map을 써야 했다. 각 아이템을 순회하면서, 할인 조건에 맞으면 새로운 객체를 만들어 반환하고, 아니면 기존 객체를 그대로 반환해야 했다.

// B안: 새로운 데이터를 만들어 반환하는 함수
function applyDiscount_B(items) {
  const newItems = items.map(item => {
    if (item.price > 20000) {
      // 새 객체를 만들어서 반환
      return { ...item, discountPrice: item.price * 0.9 };
    } else {
      // 기존 객체 그대로 반환
      return item;
    }
  });
  return newItems;
}

“다 짰어. 역시 A안이 훨씬 간단한 것 같은데.” 솔라는 내심 자신의 생각이 맞았다고 여기며 말했다. 루나는 말없이 테스트용 원본 데이터를 만들었다.

const cart = [
  { id: 1, name: "노트북 파우치", price: 25000 },
  { id: 2, name: "무선 마우스", price: 18000 },
  { id: 3, name: "기계식 키보드", price: 120000 }
];

“좋아, 먼저 A안으로 할인을 적용해 보자.”

솔라는 코드를 실행하고 결과를 확인했다.

const discountedCart_A = applyDiscount_A(cart);
console.log(discountedCart_A);
/*
[
  { id: 1, name: "노트북 파우치", price: 25000, discountPrice: 22500 },
  { id: 2, name: "무선 마우스", price: 18000 },
  { id: 3, name: "기계식 키보드", price: 120000, discountPrice: 108000 }
]
*/

“봐, 완벽하게 할인 적용됐지? 이제 이걸 화면에 보여주기만 하면 돼.” 솔라가 자신만만하게 말했다.

바로 그때, 루나가 새로운 요구사항을 던졌다. “아, 기획이 추가됐어. 결제 화면 상단에 ‘총 할인 금액’을 보여줘야 하고, 그 옆에는 사용자가 헷갈리지 않도록 할인 전 원래 장바구니 목록도 함께 보여주기로 했어.”

“문제없지.” 솔라는 대수롭지 않게 답했다. ‘총 할인 금액’은 discountedCart_A로 계산하면 되고, ‘할인 전 목록’은 원래 있던 cart 변수를 쓰면 되니까. 그녀는 cart 변수를 다시 출력해보았다.

console.log(cart);

결과가 화면에 나타나는 순간, 솔라의 표정이 굳었다.

/*
[
  { id: 1, name: "노트북 파우치", price: 25000, discountPrice: 22500 },
  { id: 2, name: "무선 마우스", price: 18000 },
  { id: 3, name: "기계식 키보드", price: 120000, discountPrice: 108000 }
]
*/

“어?”

할인 전 원본 목록을 기대했지만, 화면에 나타난 것은 할인이 이미 적용된 discountedCart_A와 똑같은 내용이었다. cart 변수 자체가 오염되어 있었다. 할인 전 가격 정보는 사라져 버렸다.

“아…” 솔라는 그제야 깨달았다. applyDiscount_A 함수가 cart 배열 내부의 객체들을 직접 ‘수정’했기 때문에, 원본 cart 자체가 영구적으로 변해버린 것이었다. 할인 전 상태로 돌아갈 방법이 없었다. 만약 다른 기능이 또 cart 원본 데이터를 필요로 한다면, 예상치 못한 버그로 이어질 게 뻔했다.

루나는 아무 말 없이 B안을 실행할 차례라는 듯 눈짓했다. 솔라는 cart 데이터를 초기 상태로 되돌리고, 이번에는 B안 함수를 실행했다.

// cart 데이터를 다시 초기화
const originalCart = [
  { id: 1, name: "노트북 파우치", price: 25000 },
  { id: 2, name: "무선 마우스", price: 18000 },
  { id: 3, name: "기계식 키보드", price: 120000 }
];

const discountedCart_B = applyDiscount_B(originalCart);

console.log("B안 결과:", discountedCart_B);
console.log("원본 데이터:", originalCart);

결과는 명확했다. discountedCart_B에는 할인이 적용된 새로운 배열이 담겨 있었고, originalCart는 함수 실행 전과 똑같은, 아무런 변화 없는 깨끗한 상태를 유지하고 있었다. ‘할인 전 목록’을 보여달라는 추가 요구사항도 아무 문제 없이 해결할 수 있었다.

두 개의 터미널 창에 나란히 찍힌 결과를 보며 솔라는 깊은 한숨과 함께 중얼거렸다.

“단순히 편하고 안 편하고의 문제가 아니었네. 원본을 직접 건드리는 건, 내가 모르는 사이에 데이터의 ‘역사’를 지워버리는 거였어. 어디서 뭐가 어떻게 바뀔지 예측할 수가 없게 되는구나.”

루나가 그제야 입을 열었다. “바로 그거야. 데이터가 언제, 어디서, 어떻게 변했는지 추적하기 어려워지는 걸 ‘예측 불가능성’이 높아진다고 해. 반대로 원본은 그대로 두고, 변화가 필요할 때마다 새로운 복사본을 만드는 방식을 ‘불변성(Immutability)‘을 지킨다고 말해. 원본 데이터의 무결성을 지켜서, 프로그램 전체의 동작을 예측 가능하게 만드는 거지.”

mapfilter가 왜 굳이 ‘새로운’ 배열을 반환하도록 설계되었는지, 그 고집스러운 원칙의 이유를 몸으로 체감하는 순간이었다. 그것은 문법적 편의를 넘어선, 안정적인 소프트웨어를 위한 철학이었다.

“알겠어. 원본을 지키는 게 프로그램의 예측 가능성과 안정성을 위해 정말 중요하구나. 그럼 이건 그냥 코딩할 때 지키면 좋은 ‘좋은 습관’ 같은 거네.”

솔라의 말에 루나는 고개를 저었다.

“좋은 습관 이상이야. 특히 React에서는, 이게 단순한 권장 사항이 아니라 거의 필수적인 규칙처럼 작동해. React가 변화를 감지하고 화면을 업데이트하는 방식과 아주 깊게 연관되어 있거든.”

솔라의 눈이 다시 반짝였다. “필수적이라고? React는 원본을 바꾸면 무슨 큰일이라도 나는 거야?”

버그를 피하는 차원을 넘어, React의 핵심 원리와 연결된다는 말에 솔라의 궁금증은 새로운 국면으로 접어들고 있었다.

3장: React와 불변성: 예측 가능한 상태 관리의 핵심

루나는 대답 대신, 솔라의 자리로 돌아가 브라우저에 새 탭을 열고 익숙한 로고가 그려진 온라인 코드 에디터를 띄웠다. 몇 번의 타이핑으로 아주 간단한 React 컴포넌트의 뼈대가 화면에 나타났다.

import React, { useState } from 'react';

function Profile() {
  const [user, setUser] = useState({ name: '솔라', likes: 0 });

  const handleLike = () => {
    // '좋아요' 버튼을 누르면 이 함수가 실행됩니다.
  };

  return (
    <div>
      <h1>{user.name}</h1>
      <p>좋아요: {user.likes}</p>
      <button onClick={handleLike}>좋아요</button>
    </div>
  );
}

화면에는 ‘솔라’라는 이름과 ‘좋아요: 0’이라는 텍스트, 그리고 ‘좋아요’ 버튼이 렌더링되어 있었다. 이전 장에서 다루었던 복잡한 쇼핑카트 대신, 훨씬 단순하고 명확한 상황이 눈앞에 펼쳐졌다.

“큰일이 나는지 직접 확인해 보자.” 루나가 말했다. “저 handleLike 함수를 채워야 해. 버튼을 누르면 likes 숫자가 1씩 늘어나게. 두 가지 방법이 있어.”

루나는 컴포넌트 코드 옆에 두 개의 주석 블록을 만들었다.

// 방법 1: user 객체를 직접 수정하기
// user.likes = user.likes + 1;
// setUser(user);

// 방법 2: 새로운 user 객체를 만들어서 전달하기
// const newUser = { ...user, likes: user.likes + 1 };
// setUser(newUser);

솔라는 두 코드를 훑어보았다. 1번 방법이 훨씬 직관적으로 느껴졌다. 그냥 user 객체의 likes 값을 1 증가시키고, 상태를 업데이트하라고 알려주면 되는 것 아닌가? 결국엔 화면에만 잘 보이면 되는 거니까.

“일단 1번 방법으로 해볼게. 이게 더 간단하잖아.”

솔라는 자신 있게 handleLike 함수 안에 코드를 채워 넣고, console.log를 추가해 값이 정말 변하는지 확인하기로 했다.

const handleLike = () => {
  user.likes = user.likes + 1;
  console.log('값이 바뀌었나?', user); // 확인용 로그
  setUser(user);
};

코드를 저장하자 화면이 자동으로 새로고침되었다. ‘좋아요: 0’. 솔라는 자신만만하게 ‘좋아요’ 버튼을 클릭했다.

그리고 또 한 번. 또 한 번.

개발자 도구의 콘솔 창에는 로그가 차곡차곡 쌓였다.

값이 바뀌었나? {name: '솔라', likes: 1} 값이 바뀌었나? {name: '솔라', likes: 2} 값이 바뀌었나? {name: '솔라', likes: 3}

콘솔에서는 likes 값이 분명히 1, 2, 3으로 증가하고 있었다. 하지만 정작 브라우저 화면의 ‘좋아요’ 숫자는 미동도 없이 ‘0’에 머물러 있었다. 데이터는 변했지만, 화면은 변하지 않았다. 솔라는 잠시 혼란에 빠졌다.

“어? 왜… 왜 안 바뀌지? 콘솔에는 분명히 숫자가 올라가는데.”

이것이 루나가 말한 ‘큰일’이었을까? 프로그램이 터지거나 에러 메시지가 뿜어져 나오는 극적인 상황은 아니었다. 그보다 더 기묘하고 예측하기 어려운 문제였다. 데이터와 화면이 따로 노는, ‘소리 없는 버그’였다.

“React가 화면을 다시 그려야겠다고 판단하는 기준이 뭘까?” 루나가 조용히 물었다. “하루 종일 모든 변수의 속성 하나하나를 감시하고 있을까? 아니면 더 효율적인 방법이 있을까?”

질문의 의도를 곰곰이 생각하던 솔라의 머릿속에 이전 장에서 다뤘던 ‘불변성’ 개념이 스쳐 지나갔다. 원본과 복사본. 오래된 것과 새로운 것.

“설마… React는 우리가 setUser에 넘겨준 게 ‘새로운’ 객체인지 아닌지만 확인하는 거야?”

루나는 대답 대신, 솔라가 2번 방법으로 코드를 수정하길 기다렸다. 솔라는 주석 처리해두었던 1번 방법을 지우고 2번 방법을 handleLike 함수에 채워 넣었다.

const handleLike = () => {
  // 방법 2: 새로운 user 객체를 만들어서 전달하기
  const newUser = { ...user, likes: user.likes + 1 };
  setUser(newUser);
};

코드를 저장했다. 화면은 다시 ‘좋아요: 0’으로 초기화되었다. 솔라는 약간의 긴장감과 함께 ‘좋아요’ 버튼을 다시 클릭했다.

‘좋아요: 1’

클릭하자마자 숫자가 바뀌었다. 다시 누르자 ‘좋아요: 2’가 되었다. 이제는 콘솔 값과 화면이 완벽하게 동기화되어 움직였다.

솔라는 의자 등받이에 몸을 기댔다. 퍼즐의 마지막 조각이 맞춰지는 느낌이었다.

“알겠다. React는 똑똑한 척하지만 사실은 게으른 감시원 같은 거였어. 상태 변수 안에 있는 likes 값이 0에서 1로 바뀌었는지 일일이 들여다보지 않는 거야. 그냥 setUser가 호출될 때, 예전에 받은 user 객체랑 지금 받은 newUser 객체가 완전히 ‘같은 놈’인지 ‘다른 놈’인지만 비교하는 거네.”

솔라는 두 가지 방법을 다시 떠올렸다.

1번 방법에서는 user 객체의 내용물만 바꿨을 뿐, 객체 자체는 원래의 그것과 동일했다. React 입장에서는 같은 주소를 가리키는 ‘같은 놈’이 들어왔으니, “어, 바뀐 거 없네. 그럼 화면 다시 그릴 필요 없겠다” 하고 그냥 넘어가 버린 것이었다.

2번 방법은 달랐다. { ...user } 구문이 기존 user 객체의 내용물을 복사해서 완전히 ‘새로운’ 객체를 만들었다. React는 이 새로운 객체를 받고는, “어? 예전 거랑 다른 ‘새로운 놈’이 왔네. 뭔가 바뀌었나 보다. 화면 다시 그려야지!” 하고 렌더링을 실행한 것이다.

“맞아.” 루나가 조용히 덧붙였다. “이걸 ‘얕은 비교(shallow comparison)‘라고 해. 객체 속까지 깊게 비교하는 건 비용이 너무 비싸니까, 그냥 겉만 보고 판단하는 거지. 그래서 React에게 ‘상태가 변했다’고 알려주는 가장 확실한 방법은, 완전히 새로운 객체나 새로운 배열을 만들어서 주는 거야. 불변성을 지키는 것이 ‘좋은 습관’이 아니라, React의 렌더링 메커니즘을 작동시키는 ‘필수 규칙’인 이유지.”

이제 map이나 filter가 왜 항상 ‘새로운 배열’을 반환하는지, 그 고집스러운 설계 철학이 React의 생태계와 얼마나 깊게 맞물려 있는지 선명하게 이해되었다. 예측 가능성을 높이는 것을 넘어, UI를 업데이트하기 위한 핵심 조건이었던 것이다.

솔라는 만족스럽게 고개를 끄덕이다가, 문득 승리의 증거가 된 2번 방법의 코드를 다시 들여다봤다.

const newUser = { ...user, likes: user.likes + 1 };

“이제 상태를 업데이트하려면 무조건 새로운 걸 만들어야 한다는 건 확실히 알겠어. 그런데 이 코드… { ...user } 이 점 세 개짜리 문법 말이야. 지금은 간단한 객체니까 그냥 ‘복사하는 편리한 방법’ 정도로 보이는데, 만약에 객체 구조가 훨씬 복잡해지거나, 배열의 여러 아이템 중에 딱 하나만 바꿔야 할 때는 어떻게 써야 하는 거지? 이 문법이 정확히 어떻게 동작하는지 알아야 제대로 써먹을 수 있을 것 같아.”

4장: 구조 분해와 Spread: 불변적 업데이트의 동반자

이전의 Profile 컴포넌트 코드는 여전히 화면에 남아있었다. ‘좋아요’ 버튼은 이제 의도대로 완벽하게 작동했다. 솔라의 질문 이후, 루나는 잠시 생각에 잠겨 있다가 조용히 키보드를 가져가 코드를 수정하기 시작했다. useState의 초기값이 조금 더 복잡해졌다.

// 수정 전
const [user, setUser] = useState({ name: '솔라', likes: 0 });

// 수정 후
const [user, setUser] = useState({
  name: '솔라',
  likes: 0,
  details: {
    email: 'sola@example.com',
    theme: 'light'
  }
});

객체 안에 details라는 새로운 객체가 추가되었다. 마치 서랍 안에 작은 상자가 하나 더 들어간 모양새였다. 루나는 코드를 저장하고, 바뀐 데이터 구조를 반영하도록 화면 표시 코드도 간단히 수정했다. 화면에는 이제 사용자의 이메일과 테마 설정이 함께 보였다.

“자, 새로운 미션이야.” 루나가 말했다. “프로필 설정에서 ‘다크 모드’로 전환하는 버튼을 추가했다고 치자. details 객체 안의 theme 값을 ‘light’에서 ‘dark’로 바꿔야 해. 물론, 우리가 방금 배운 불변성 규칙을 지키면서.”

솔라는 자신 있게 고개를 끄덕였다. 원리는 파악했다. ‘새로운’ 객체를 만들어서 setUser에 넘겨주면 된다. 이전 장에서 ‘좋아요’를 늘릴 때 썼던 그 코드가 머릿속에 선명했다.

const newUser = { ...user, likes: user.likes + 1 };

솔라는 그 패턴을 자신만만하게 응용했다.

“간단하지. ...user로 일단 user 객체를 복사하고, details 값만 새로 바꿔주면 되잖아?”

하지만 코드를 막상 쓰려니 손이 멈칫했다. details 자체가 객체였다. 어떻게 해야 할까? 잠시 고민하던 솔라는 일단 가장 직관적인 방법으로 코드를 작성했다.

const handleThemeChange = () => {
  const newUser = { ...user }; // 1. user 객체를 얕게 복사하고
  newUser.details.theme = 'dark'; // 2. 그 복사본의 details.theme을 수정한다
  setUser(newUser); // 3. 새로운 객체를 상태로 설정한다
};

이 코드에 따르면, 새로운 newUser 객체를 만들었으니 React가 변화를 감지할 것이고, 그 안의 theme 값도 바꿨으니 화면도 바뀔 것이다. 논리적으로 완벽해 보였다. 솔라는 코드를 실행하고 확인용 로그를 추가했다.

console.log('업데이트 후 newUser:', newUser);
console.log('원본 user는 그대로일까?', user);

버튼을 클릭했다. 화면의 테마는 ‘light’에서 ‘dark’로 바뀌었다. 성공이다.

“됐네! 봐, 화면도 잘 바뀌고…”

솔라가 승리를 선언하려는 순간, 콘솔에 찍힌 두 번째 로그 메시지가 눈에 들어왔다.

원본 user는 그대로일까? { name: '솔라', likes: 0, details: { email: 'sola@example.com', theme: 'dark' } }

솔라의 표정이 다시 굳었다. newUser뿐만 아니라, 안전하게 보존되어야 할 원본 userdetails.theme 값까지 ‘dark’로 오염되어 있었다. 분명 { ...user }를 통해 새로운 객체를 만들었는데도, 원본 데이터가 또다시 훼손된 것이다. 이전 장의 장바구니 예제에서 겪었던 악몽이 재현되었다.

“아니, 왜? 이번엔 분명히 ...으로 새 객체를 만들었는데 왜 원본이 바뀌는 거야?”

루나는 말없이 솔라의 코드에서 { ...user } 부분을 가리켰다.

“그 점 세 개, 스프레드(spread) 연산자는 복사기가 아니라 ‘포장 풀기’에 가까워. user라는 상자를 열어서 그 내용물인 name, likes, details를 꺼내 새로운 상자인 newUser에 한 겹씩 다시 담는 거야.”

루나는 펜을 들어 메모지에 그림을 그리기 시작했다. namelikes는 그냥 값이었기에 새 상자에 그대로 복사되었다. 하지만 details는 값이 아니라, 또 다른 상자를 가리키는 ‘주소’였다. 스프레드 연산자는 그 ‘주소’ 자체만 복사해서 newUser에 담았다. 결국 newUser.detailsuser.details는 이름만 다를 뿐, 똑같은 주소를 가리키는, 즉 동일한 ‘안쪽 상자’를 공유하는 셈이 되어버렸다.

“그래서 newUser.details.theme을 바꾸는 건, 결국 공유하고 있던 바로 그 ‘안쪽 상자’의 내용물을 바꾸는 행위였던 거지. 원본 user도 영향을 받을 수밖에.”

솔라는 그제야 깨달았다. 스프레드 연산자는 마법의 복사기가 아니었다. 딱 한 단계 깊이까지만 내용물을 꺼내주는 ‘얕은 복사’ 도구였다. 객체 안에 또 다른 객체가 있는 중첩 구조에서는 힘을 쓰지 못했다. 자신의 생각이 ‘코드를 간결하게 만드는 문법’이라는 표면적인 이해에만 머물러 있었음을 인정할 수밖에 없었다.

“그럼… 이런 중첩된 객체는 불변성을 지키면서 어떻게 바꿔야 해?”

“포장을 한 겹만 풀 게 아니라, 우리가 바꾸고 싶은 곳까지 계속해서 풀어야지.”

루나는 솔라의 코드를 수정했다.

const handleThemeChange = () => {
  const newUser = {
    ...user, // 1. 바깥 상자를 먼저 풀고
    details: { // 2. 안쪽 상자를 새로 만드는데,
      ...user.details, // 3. 그 내용물은 원래 안쪽 상자를 풀어서 채우고
      theme: 'dark' // 4. 바꾸고 싶은 값만 덮어쓴다
    }
  };
  setUser(newUser);
};

코드가 마치 겹겹이 쌓인 러시아 인형을 하나씩 열어보는 것처럼 보였다. 바깥 객체를 풀고(...user), 안쪽 객체도 새로 만들며 그 안을 또 풀어준(...user.details) 뒤에야, 비로소 원하는 값을 안전하게 덮어쓸 수 있었다.

이 코드로 다시 테스트하자, newUsertheme만 ‘dark’로 바뀌고 원본 user는 ‘light’ 상태를 완벽하게 유지했다.

“아하! 스프레드는 단순히 복사하는 문법이 아니라, ‘새로운 구조를 만들되, 기존 내용물을 일단 채워 넣는’ 도구구나. 그래서 바꿀 부분까지 계속 펼쳐주면서 새 판을 짜야 하는 거였어.”

솔라는 이제 스프레드 연산자가 불변성 유지에 얼마나 핵심적인 역할을 하는지 명확히 이해했다. 이것은 문법적 편의성을 넘어, 복잡한 데이터 구조를 안전하게 다루기 위한 필수 패턴이었다.

“참, 반대 방향도 있어.” 루나가 덧붙였다. “스프레드가 객체를 펼쳐서 ‘집어넣는’ 거라면, ‘꺼내는’ 문법도 있거든. 구조 분해 할당(destructuring assignment)이라고 해.”

루나는 간단한 예시를 보여주었다.

const { name, likes } = user;
console.log(name); // '솔라'
console.log(likes); // 0

“객체나 배열에서 필요한 값만 쏙쏙 골라서 변수로 만드는 거야. 스프레드랑 한 쌍처럼 쓰이지.”

솔라는 방금 완성한 불변적 업데이트 패턴과 구조 분해 할당을 번갈아 보았다. 따로따로 배울 땐 그저 편리한 문법 정도로만 보였던 것들이었다. 하지만 React의 상태 관리라는 맥락 안에서 만나자, 데이터를 안전하게 ‘분해’하고 ‘재조립’하여 새로운 상태를 창조하는 강력한 파트너로 보이기 시작했다.

솔라는 자신감을 얻었다. 이제 어떤 복잡한 객체라도 불변성을 지키며 업데이트할 수 있을 것 같았다. 그러다 문득, 장바구니나 게시판 목록처럼, 여러 개의 객체가 담긴 ‘배열’ 상태가 떠올랐다.

“좋아, 이제 복잡한 객체 하나는 자신 있어. 그런데 만약에, 이런 user 객체가 여러 개 들어있는 배열이 있고, 그중 딱 두 번째 유저의 theme만 바꿔야 한다면? 그때는 어떻게 해야 하지? map을 써야 할 것 같긴 한데… 그 안에서 또 스프레드를 써야 하고… 뭔가 조각들은 다 있는데, 이걸 어떻게 합쳐야 할지 감이 잘 안 와.”

5장: React 패턴 완성: 목록 렌더링과 데이터 관리

루나는 이전의 Profile 컴포넌트 창을 닫았다. 그리고 솔라의 마지막 질문에 대한 대답처럼, 새로운 코드 에디터 창을 열어 보였다. 화면에는 방금 막 뼈대가 완성된 듯한 간단한 ‘오늘 할 일’ 목록 애플리케이션이 나타나 있었다. 여러 개의 객체가 담긴 배열, 바로 솔라가 궁금해했던 바로 그 상황이었다.

import React, { useState } from 'react';

const initialTodos = [
  { id: 1, text: 'React 불변성 공부하기', done: true },
  { id: 2, text: '산책하기', done: false },
  { id: 3, text: '저녁 장보기', done: false },
];

function TodoList() {
  const [todos, setTodos] = useState(initialTodos);

  // 1. 할 일 추가
  // 2. 할 일 삭제
  // 3. 할 일 완료 상태 변경

  return (
    <div>
      <h1>오늘 할 일</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

화면에는 initialTodos 배열의 내용이 map 메서드를 통해 목록으로 예쁘게 렌더링되어 있었다. 솔라는 이제 map을 써서 목록을 그리는 것에는 완전히 익숙했다. 하지만 주석으로 남겨진 2번과 3번 기능, 즉 ‘삭제’와 ‘상태 변경’은 달랐다. 이건 화면에 그리는 것(Read)이 아니라, 데이터를 바꾸는(Update) 작업이었다. 특히 3번, ‘완료 상태 변경’은 솔라의 질문과 정확히 일치했다. 배열 속 여러 객체 중, 단 하나의 객체만 골라서 그 안의 done 속성을 false에서 true로 바꿔야 했다.

“네 질문에 대한 답이 이 안에 다 있어.” 루나가 말했다. “배열에서 딱 두 번째 할 일, ‘산책하기’의 done 상태를 바꿔주는 handleToggle 함수를 만들어볼래? id가 2인 항목을 찾아서 바꾸면 되겠지.”

솔라는 키보드를 잡았다. 그동안 배운 조각들이 머릿속을 맴돌았다. ‘일단 새로운 배열을 만들어야 하니까 map을 써야겠지. map으로 todos 배열을 쭉 훑다가, id가 2인 todo를 만나면… 그 todo 객체만 새로운 객체로 바꿔치기해야 해. 새로운 객체는 ... 스프레드 연산자로 만들고. 아, 그리고 id가 2가 아닌 다른 todo들은? 걔네들은 그냥 그대로 돌려줘야 새로운 배열에 포함될 테고.’

생각의 흐름을 따라, 솔라는 코드를 써 내려갔다.

const handleToggle = (id) => {
  const newTodos = todos.map(todo => {
    if (todo.id === id) {
      // id가 일치하면, 새 객체를 만들어서 반환
      return { ...todo, done: !todo.done };
    } else {
      // id가 다르면, 원래 객체를 그대로 반환
      return todo;
    }
  });
  setTodos(newTodos);
};

코드는 제법 길었지만, 논리적으로는 완벽했다. map으로 새 배열의 판을 깔고, if문으로 타겟을 찾아, spread 연산자로 콕 집어 내용물을 바꾼 새 객체를 만들어 넣는다. 각개격파하듯 따로 배웠던 문법들이 하나의 목표를 위해 유기적으로 협력하는 첫 순간이었다. 솔라는 각 할 일 항목에 onClick 핸들러를 추가하고 ‘산책하기’ 항목을 클릭했다. 아직 화면에 done 상태가 시각적으로 표시되지는 않았지만, 개발자 도구의 state 변화를 통해 todos 배열이 의도대로 업데이트된 것을 확인했다. id가 2인 객체의 done 값이 true로 바뀌어 있었고, 다른 객체들은 그대로였다. 원본은 훼손되지 않았다.

“된다! map으로 순회하면서 spread로 바꿔치기. 이 패턴이구나.”

솔라는 만족스러워하다가, 자신이 짠 코드를 다시 보았다. 각각의 문법은 알겠지만, 이것들이 합쳐졌을 때 어떤 시너지를 내는지 모르겠다던 처음의 막막함이 사라지고 있었다. 마치 따로따로 기능을 익혔던 여러 공구들을 가져와 마침내 하나의 가구를 조립해낸 기분이었다.

루나는 이번엔 ‘미완료된 할 일만 보기’ 필터 기능을 추가해보자고 제안했다. 이건 filter 메서드의 독무대였다.

const activeTodos = todos.filter(todo => !todo.done);

그리고 화면에 보여줄 목록을 todos 대신 activeTodos로 바꾸자, donetrue인 ‘React 불변성 공부하기’ 항목이 화면에서 사라졌다.

솔라는 잠시 숨을 고르며 자신이 완성한 TodoList 컴포넌트 전체를 훑어보았다. 처음 보는 복잡한 코드 덩어리가 아니었다. 이제는 모든 줄이 익숙한 패턴의 조합으로 보였다.

  • useState: 데이터의 유일한 ‘원본’을 보관하는 창고.
  • filter: ‘삭제하기’ 기능에서 특정 항목을 제외한 새 배열을 만들거나, ‘필터링’ 기능에서 특정 조건의 항목만 보여주기 위해 원본에서 파생된 새 배열을 만들었다.
  • spread: ‘추가하기’ 기능에서는 setTodos([...todos, newTodo]) 형태로 기존 배열에 새 항목을 더한 새 배열을 만들었고, ‘수정하기’ 기능에서는 객체 내부의 값을 바꾸기 위해 사용되었다.
  • map: ‘수정하기’ 기능에서는 배열 전체를 순회하며 특정 항목만 바꿔치기한 새 배열을 만들었고, 마지막엔 화면에 목록을 <li> 태그로 ‘렌더링’하기 위해 사용되었다.

그제야 솔라는 깨달았다. map, filter, spread는 단순히 자바스크립트의 편리한 문법이 아니었다. 이것들은 React의 상태 관리 철학, 즉 ‘원본은 하나만 두고, 변화는 새로운 복사본을 통해 표현한다’는 불변성 원칙을 가장 효율적이고 우아하게 구현하기 위한 핵심 도구 세트였다. 따로따로 존재하던 개념들이 ‘React 데이터 다루기’라는 하나의 큰 그림 안에서 각자의 역할을 수행하며 완벽한 시스템을 이루고 있었다.

“이제 알겠어…” 솔라가 나지막이 중얼거렸다. “이것들은 그냥 문법이 아니라, React가 화면을 그리고 데이터를 다루는 방식 그 자체였네. 원본을 안전하게 지키면서, 필요한 모든 변화와 화면 표시를 ‘새로운 복사본’만으로 처리하는 패턴.”

솔라는 자신감이 붙었다. 이제 어떤 목록 데이터가 와도 다룰 수 있을 것 같았다. 그녀는 혼잣말처럼 앞으로 해야 할 일을 시뮬레이션해 보았다.

“만약 ‘할 일 텍스트 수정하기’ 버튼을 만든다면? 그것도 map으로 시작해야겠지. 수정하려는 id를 찾아서, 일치하면 { ...todo, text: newText } 처럼 새로운 객체를 돌려주고, 나머진 그대로. 간단하네.”

더 이상 루나의 도움이 필요 없었다. 머릿속에서 이미 다음 기능의 코드가 완성되고 있었다. 불변성을 지키며 데이터를 다루고 UI를 업데이트하는 일련의 과정이, 낯선 마법이 아닌 체계적인 공학의 영역으로 들어온 순간이었다.