Frontend 23

React 이벤트 핸들링: 삭제 요청을 위로 올리는 방법

삭제 버튼은 PostItem에 있는데 state는 App에 있어서 어떤 컴포넌트가 삭제를 실제로 처리해야 하는지 헷갈린다.

근거 · 교안 p150-p162

React 이벤트 핸들링: 삭제 요청을 위로 올리는 방법 대표 이미지

1장: 삭제는 누가 책임져야 할까요?

솔라의 손가락이 키보드 위를 잠시 떠돌다 멈췄다. 화면에는 직접 만든 작은 블로그 앱이 떠 있었다. 여러 개의 게시글이 목록으로 나열되어 있고, 각 게시글 옆에는 작은 ‘삭제’ 버튼이 달려 있었다. 솔라는 방금 그 버튼에 기능을 추가하려던 참이었다. 하지만 어딘가 막힌 느낌이 들었다.

“언니, 잠깐만. 이거 이상해.”

서재 한쪽에서 책을 읽던 루나가 고개를 들었다. 솔라는 의자를 돌려 루나를 보며 말을 이었다.

“삭제 버튼이 PostItem 컴포넌트 안에 있잖아. 그럼 삭제하는 기능도 당연히 PostItem이 직접 처리해야 하는 거 아냐? 그게 제일 간단하고 효율적이잖아. 그런데 막상 하려니까… PostItem 안에서는 전체 글 목록에 접근할 수가 없어.”

솔라의 말에는 확신과 혼란이 섞여 있었다. 버튼이 있는 곳에서 이벤트를 처리하는 것. 그건 지극히 당연해 보였다. 계산기가 있으면 계산기에서 계산을 하고, 스위치가 있으면 스위치에서 불을 켜는 것처럼 말이다.

루나는 자리에서 일어나 솔라의 책상으로 다가왔다. 모니터에 떠 있는 코드를 잠시 들여다보더니, 키보드나 마우스 대신 책상 위에 놓인 빈 노트와 펜을 집어 들었다.

“잠깐, 코드는 그대로 두고. 우리 그림부터 하나 그려볼까?”

루나는 노트 위에 커다란 사각형을 그리고 App이라고 적었다.

“우리 앱의 가장 큰 상자지. 여기서부터 시작해보자. 솔라 네가 직접 그려봐. 이 안에 어떤 컴포넌트들이 있지?”

솔라는 펜을 건네받았다. 그리고는 잠시 생각하더니 App 사각형 안에 PostList라는 조금 더 작은 사각형을 그렸다. 다시 그 PostList 안에 PostItem이라는 더 작은 사각형 여러 개를 나란히 그렸다. 익숙한 컴포넌트 계층 구조였다.

“됐어. AppPostList를 품고, PostList는 여러 개의 PostItem을 가지고 있어.”

“좋아.” 루나가 고개를 끄덕였다. “그럼 제일 중요한 질문. 이 글 목록 전체, 그러니까 posts라는 이름의 데이터 배열은 지금 어떤 상자가 가지고 있지? 그 상자에 별표를 그려줘.”

솔라는 망설임 없이 가장 바깥쪽의 App 사각형 한구석에 별표를 그렸다.

“여기. App에서 useState로 상태를 만들었으니까.”

“맞아.” 루나는 별표가 그려진 App 컴포넌트를 가리켰다. “데이터의 진짜 주인은 App이야. 그럼 PostItem 상자들은 뭘 알고 있을까?”

“음… AppPostList를 거쳐서 넘겨준 자기 자신의 데이터? ‘첫 번째 글’이라든지, ‘두 번째 글’이라든지 하는 제목과 내용 같은 거. props로 받았으니까.”

바로 그 지점이었다. 루나는 PostItem 사각형 중 하나를 펜 끝으로 톡톡 두드렸다.

“이 PostItem은 자기가 ‘첫 번째 글’이라는 건 알아. 하지만 자기 말고 ‘두 번째 글’이나 ‘세 번째 글’이 있다는 사실은 전혀 모르지. 전체 목록이 어떻게 생겼는지도 모르고.”

솔라는 그림을 가만히 들여다보았다. App 상자 안에 그려진 별표와, 그와는 멀리 떨어진 작은 PostItem 상자들이 눈에 들어왔다.

루나가 말을 이었다. “자기 자신에 대한 정보만 아는 PostItem이, 자기가 속해있지도 않은 App의 전체 글 목록을 직접 수정할 수 있을까? 파티에 초대받은 손님이 주최자의 전체 손님 명단에서 자기 이름을 직접 지우려는 것과 비슷하지 않을까?”

“아…”

솔라의 입에서 작은 탄성이 터져 나왔다. 그 비유를 듣는 순간, 머릿속에서 무언가 제자리를 찾는 느낌이 들었다. PostItem이 삭제를 처리하는 것은 자신의 정체를 바꾸는 행위가 아니었다. 자신을 포함하고 있는 더 큰 목록의 일부가 사라지도록 만드는, 목록 자체를 바꾸는 행위였다. 그리고 그 목록의 주인은 PostItem이 아니었다.

솔라는 다시 그림을 봤다. App에 그려진 별표가 전과는 달라 보였다. 저것은 단순한 데이터가 아니라, 이 앱의 유일한 ‘원본 데이터’였다. 그리고 데이터는 저 별표에서 시작해 PostList를 거쳐 PostItem으로, 위에서 아래로 흘러내려오고 있었다.

“데이터를 가진 주인이 그걸 바꿀 책임도 지는 거네. PostItem은 그냥 자기가 받은 데이터를 화면에 보여주는 역할만 하는 거고, 삭제 버튼은 ‘나를 지워줘’라고 주인에게 알리는 신호일 뿐이구나.”

솔라는 중얼거렸다. ‘버튼이 있는 곳에서 처리해야 한다’는 생각은 컴포넌트의 역할이 아니라, 그저 물리적인 위치에만 집중한 착각이었다.

“그래. ‘데이터는 아래로 흐른다’는 원칙이 바로 그 말이야. 상태를 변경하는 책임은 그 상태를 소유한 컴포넌트에 있어.”

이제 책임자가 누구인지는 명확해졌다. 솔라의 표정이 환해졌다가, 금세 새로운 궁금증으로 바뀌었다.

“좋아, 책임자가 누군지는 알겠어. App이야. 그런데… 저 아래에 있는 PostItem에서 버튼을 클릭했다는 사실을 App이 어떻게 알 수 있지? 신호를 위로 올려 보내야 하잖아. 그건 어떻게 하는 거야?“

2장: 부모에게 ‘삭제’라고 말하는 방법

솔라는 이전 장에서 루나와 함께 그렸던 컴포넌트 구조도를 가만히 들여다보고 있었다. App에서 시작된 데이터의 흐름을 나타내는 아래쪽 화살표들은 이제 명확하게 이해되었다. 데이터는 주인이 아래로 내려보내는 것이었다. 하지만 여전히 해결되지 않은 문제가 있었다. 가장 아래쪽 PostItem에서 보낸 신호가 어떻게 가장 위쪽의 App까지 닿을 수 있을까? 솔라의 손가락이 PostItem 상자에서 App 상자까지, 화살표와는 반대 방향으로 허공을 더듬었다. 마치 벽으로 막힌 길을 거슬러 올라가려는 듯 어색했다.

그 모습을 지켜보던 루나가 펜을 다시 집어 들었다. 솔라는 루나가 당연히 PostItem에서 App으로 향하는 위쪽 화살표를 그려줄 것이라 기대했다. 하지만 루나는 정반대의 행동을 했다. 그녀는 App에서 PostList로, 그리고 PostList에서 PostItem으로 이어지는 새로운 아래 방향 점선을 그렸다. 마치 또 다른 데이터가 흘러가는 것처럼. 솔라의 미간이 좁아졌다. 위로 가는 길이 궁금한데, 왜 또 아래로 내려가는 길을 그리는 걸까.

“언니, 잠깐. 신호는 위로 보내야 하잖아. 그런데 왜 아래로 가는 길을 또 그려?”

솔라의 목소리에는 당혹감이 묻어났다. 혹시 React에는 컴포넌트 계층을 무시하고 부모에게 직접 메시지를 보낼 수 있는 특별한 마법 같은 기능이 있는 게 아닐까? 아니면 ‘이벤트가 위로 올라간다’는 말이 그냥 비유적인 표현이고 실제로는 불가능한 일일지도 모른다는 생각까지 들었다.

“이벤트가 그냥 알아서 부모까지 전해지는 건 아니야.”

루나는 점선 화살표 끝에 물음표를 그려 넣으며 말했다.

“우리가 직접 통신 채널을 만들어줘야 해. 데이터처럼. 그런데 데이터가 아니라, 다른 걸 내려보내는 거지.”

루나는 App 컴포넌트 상자 안에 handleDelete()라는 함수를 작게 적었다.

“이게 App에 있는, 삭제를 실제로 처리할 직원이라고 생각해봐. PostItem은 이 직원에게 연락해야 해. 그런데 PostItemApp의 전화번호를 몰라.”

“그치. PostItem은 자기밖에 모르니까.”

“그래서 App이 자식인 PostList에게 ‘혹시 네 아래에서 삭제해달라는 요청이 오면 이걸로 연락해’ 하면서 이 직원의 직통 전화번호, 즉 handleDelete 함수를 넘겨주는 거야.”

루나는 App에서 PostList로 향하는 점선 위에 onDelete={handleDelete}라고 썼다.

“이게 props로 함수를 전달하는 모습이야. PostListonDelete라는 이름으로 전화번호를 받은 거지.”

“아… 그럼 PostList는 그걸 또 자기 자식인 PostItem한테 전달해주고?”

솔라가 루나의 의도를 알아챘다. 솔라는 펜을 받아 PostList에서 PostItem으로 향하는 점선 위에 onDelete={props.onDelete}라고 똑같이 적었다. 데이터가 단계별로 아래로 내려갔던 것처럼, ‘연락처’ 역시 단계별로 전달되고 있었다. 이제 가장 아래 PostItem 상자는 onDelete라는 이름의 prop을 갖게 되었다.

“맞아. 이제 PostItem은 ‘삭제’ 버튼이 눌리면 뭘 해야 할까?”

“자기가 props로 받은 onDelete를… 호출하면 되겠네!”

솔라는 외치며 PostItem 상자 안의 ‘삭제 버튼’ 옆에 onClick={() => props.onDelete(id)}라고 적었다. 그 순간, 모든 것이 연결되었다.

신호가 아래에서 위로 ‘올라간다’는 것은 마법이 아니었다. 부모가 자식에게 미래에 자신을 호출할 수 있는 ‘방법(함수)’ 자체를 props를 통해 미리 내려보내는 것이었다. 자식은 그 방법이 정확히 무엇인지는 몰라도 된다. 그저 약속된 상황(버튼 클릭)이 발생했을 때, 부모가 쥐여준 그 ‘연락처(onDelete)‘로 전화를 걸기만 하면 되는 것이었다. 전화벨은 자식이 아닌 부모의 집무실(App)에서 울리게 된다.

“이벤트가 위로 올라간다는 게 진짜 이벤트가 물리적으로 상승하는 게 아니었구나. 부모가 자식한테 ‘무슨 일 생기면 이걸로 연락해’ 하고 함수를 미리 건네주는 거였어. 일종의 원격 조종 장치를 주는 거네.”

솔라의 얼굴에 환한 미소가 번졌다. 막혀 있던 길의 원리가 보이자 시야가 트이는 기분이었다. ‘데이터는 아래로, 이벤트는 위로’라는 말이 이제는 추상적인 구호가 아니라, props를 통해 데이터와 함수를 아래로 내려보내고, 그 함수 호출을 통해 부모에게 알림이 도달하는 구체적인 설계 패턴으로 보이기 시작했다.

“보통 이렇게 자식에게서 발생한 이벤트를 처리하기 위해 전달하는 함수 prop에는 on이라는 접두사를 붙여서 onDelete, onChange처럼 이름을 짓는 관례가 있어. ‘삭제가 일어났을 때’, ‘변화가 일어났을 때’라는 뜻을 명확히 해주거든.”

루나의 설명에 솔라는 고개를 끄덕였다. 이제 PostItem의 삭제 버튼과 ApphandleDelete 함수를 연결할 수 있게 되었다. 자신감이 붙은 솔라는 다시 키보드로 손을 옮겼다. App.js 파일에 handleDelete 함수를 만들고, PostListPostItem을 거쳐 onDelete prop을 전달하는 코드를 망설임 없이 작성했다.

이제 정말로 PostItem의 삭제 버튼을 누르면 App에 있는 handleDelete 함수가 실행될 것이다. 그런데 솔라의 손가락이 다시 멈칫했다.

“좋아. 이제 PostItem의 외침이 App까지 닿았어. handleDelete 함수가 호출됐는데… 이 함수 안에서 대체 뭘 해야 posts 배열에서 정확히 그 항목이 사라지게 만들 수 있지? 원본 데이터를 직접 건드리면 안 된다고 들었던 것 같은데…“

3장: 불변의 법칙: 안전하게 상태 삭제하기

화면에는 App.js 파일이 열려 있었다. 이전과 달라진 점이 있다면, handleDelete 함수 안이 더 이상 비어있지 않다는 것이었다. 솔라는 자식 컴포넌트로부터 id를 성공적으로 받아온 뒤, 그 id를 이용해 전체 posts 배열에서 해당 게시글을 삭제하는 코드를 막 작성한 참이었다.

const handleDelete = (idToDelete) => {
  // 여기서 어떻게 삭제하지?
  const indexToDelete = posts.findIndex(post => post.id === idToDelete);
  posts.splice(indexToDelete, 1); // 배열에서 해당 인덱스를 직접 제거
  setPosts(posts); // 변경된 배열로 상태 업데이트...?
};

코드는 논리적으로 보였다. 지워야 할 항목의 인덱스를 찾고, 자바스크립트의 splice 메서드로 배열에서 그 항목을 제거한 뒤, 변경된 배열로 상태를 업데이트한다. 하지만 솔라는 만족스럽지 않았다. 키보드에서 손을 뗀 채, 자기가 작성한 코드를 미심쩍은 눈으로 바라보았다. 분명 기능적으로는 작동할지도 모르지만, 어딘가 찜찜했다. 마치 단단한 건물의 주춧돌 하나를 망치로 깨서 빼내는 듯한 느낌이었다. ‘원본 데이터를 직접 건드리면 안 된다’고 막연히 들었던 기억이 머릿속을 맴돌았다.

“언니, handleDelete 함수를 이렇게 짜봤어. 그런데… 이게 맞는 방법인지 모르겠어.”

솔라의 부름에 루나가 다가와 모니터를 들여다보았다. splice라는 단어를 본 루나는 아무 말 없이, 솔라가 이전 장에서 컴포넌트 구조도를 그렸던 노트를 다시 가져왔다. 그리고는 노트의 깨끗한 페이지에 포스트잇 세 장을 나란히 붙였다. 각 포스트잇에는 ‘게시글 1 (id:1)’, ‘게시글 2 (id:2)’, ‘게시글 3 (id:3)‘이라고 적혀 있었다.

“이게 우리 posts 상태의 원본이라고 생각해 봐.”

루나는 ‘게시글 2 (id:2)‘라고 적힌 포스트잇을 가리켰다.

“솔라 네가 짠 splice 코드는 이걸 하는 것과 같아.”

루나는 그렇게 말하며 ‘게시글 2’ 포스트잇을 떼어내 손으로 구겨버렸다. 이제 노트 위에는 두 장의 포스트잇만 남아 있었다. 원본 자체가 훼손된 것이다.

“아…”

솔라는 자기가 작성한 코드의 의미를 시각적으로 마주했다. posts.splice(...)는 원본 배열 자체에 돌이킬 수 없는 변경을 가하는 작업이었다.

“그럼 어떻게 해야 해? 원본을 건드리지 않고는 어떻게 항목을 없애?”

솔라의 질문에 루나는 구겨버렸던 포스트잇을 다시 펴서 원래 자리에 붙였다. 원본은 다시 세 장이 되었다. 그러고는 노트의 빈 공간에 새로운 포스트잇 두 장을 가져와 각각 ‘게시글 1 (id:1)’, ‘게시글 3 (id:3)‘이라고 적어서 붙였다.

“이렇게 하는 거야. 원본은 그대로 두고, 우리가 원하는 조건에 맞는 애들만 골라서 ‘새로운 목록’을 만드는 거지. 이게 filter 메서드가 하는 일이야. 원래 있던 목록을 뒤지면서 ‘너, 삭제할 id가 아니지? 그럼 새로운 목록으로 이사 가’ 하고 하나씩 물어보는 심사관처럼.”

그제야 모든 것이 명확해졌다. 삭제는 파괴가 아니라 선별이었다. 원본을 바꾸는 것이 아니라, 조건을 통과한 요소들로만 이루어진 복사본을 만드는 행위였다.

솔라의 눈이 반짝였다. “그렇구나! splice는 원본 목록 자체를 수정해버리지만, filter는 원본은 그대로 두고 새로운 목록을 돌려주는 거구나. 그럼 React 입장에서는… setPosts에 완전히 새로운 배열이 들어오니까, ‘아, 상태가 바뀌었네!’ 하고 명확하게 알아채고 화면을 다시 그려주겠구나!”

만약 splice로 원본 배열을 수정하면, 배열의 내용물은 바뀌었을지 몰라도 배열 자체의 참조(메모리 주소)는 그대로일 수 있다. React는 때로 이런 미묘한 변경을 감지하지 못해 화면을 업데이트하지 않는 문제를 일으킬 수 있었다. 하지만 filter는 언제나 새로운 배열을 반환하므로 그런 걱정이 없었다. 이것이 ‘불변성’을 지키는 것이 중요한 이유였다.

솔라는 확신에 차서 키보드로 손을 옮겼다. 방금 작성했던 splice 코드를 망설임 없이 지우고, 새롭게 handleDelete 함수를 작성했다.

const handleDelete = (idToDelete) => {
  const updatedPosts = posts.filter(post => post.id !== idToDelete);
  setPosts(updatedPosts);
};

‘삭제는 filter로 새 배열을 만들고 setPosts로 교체한다.’ 이 문장은 이제 단순히 외워야 할 규칙이 아니었다. 원본을 보존하고, 예측 가능한 상태 변화를 만들기 위한 안전하고 명확한 설계 원칙으로 다가왔다.

코드를 완성한 솔라는 만족스러운 미소를 지었다. 문득 이 원리가 삭제에만 국한되지 않는다는 생각이 들었다. 그녀는 handleDelete 함수 위에 주석을 달기 시작했다. 자신의 깨달음을 정리하고, 미래의 자신에게 남기는 메모였다.

// 상태 업데이트 원칙: 원본 배열(state)을 직접 수정하지 않는다!
// 1. 삭제(Delete): filter를 사용해 특정 항목을 제외한 '새 배열'을 만든다.
// 2. 추가(Create): spread syntax `[...posts, newPost]`로 '새 배열'을 만든다.
// 3. 수정(Update): map을 사용해 특정 항목만 변경된 '새 배열'을 만든다.
// 결국 모두 '새로운 배열을 만들어 교체한다'는 같은 원리네!

이제 솔라에게 데이터의 흐름은 완벽한 원을 그리고 있었다. App에서 posts 데이터가 아래로 흘러 PostItem에 닿고, PostItem의 버튼 클릭이 onDelete라는 파이프라인을 타고 위로 올라와 App에 신호를 보낸다. 그리고 App은 불변의 법칙에 따라 안전하게 새로운 상태를 만들어 화면을 업데이트한다. 막혔던 흐름이 시원하게 뚫린 기분이었다.