Mini Project 4 02
BookShelf: React State로 연결된 화면 전환과 데이터 흐름
화면이 여러 개 있으면 페이지가 많다는 사실만 보이고, 실제로 React가 어떤 상태를 기준으로 목록, 상세, 폼을 전환하는지 놓치기 쉽다.
근거 · 4차 React page/state 구현
1장: 라우터 오해 깨기: App.jsx가 화면 전환의 중심이다
솔라는 화면에 띄워 둔 프로젝트 가이드의 한 문장에 시선을 고정한 채였다. 손가락으로 문장을 짚어 따라가 보지만, 머릿속에선 자꾸 다른 길이 그려졌다. 분명 BookShelf 프로젝트에는 책 목록을 보여주는 화면, 한 권의 책을 상세히 보여주는 화면, 그리고 새 책을 등록하거나 기존 책을 수정하는 폼 화면이 있었다. 화면이 세 개나 되는데.
“언니, 이거 이상해.”
솔라의 말에 옆에서 조용히 코드를 보던 루나가 고개를 들었다.
“BookShelf 프로젝트 설명인데, ‘라우터 중심 설명보다 App.jsx의 상태 전환으로 화면을 오가는 흐름으로 읽는 편이 맞다’고 쓰여 있어. 근데 화면이 여러 개면 보통 React Router 같은 걸로 주소 기반으로 전환하는 거 아니야? /list는 목록, /detail/1은 1번 책 상세… 이런 식으로. 라우터 없이 어떻게 화면을 바꾼다는 거지?”
솔라의 질문에는 당연한 전제가 깔려 있었다. React에서 여러 페이지로 구성된 애플리케이션은 URL과 컴포넌트를 짝지어주는 라우터가 관리한다는 것. 그건 솔라가 여러 번 경험해 본 익숙한 패턴이었다. 하지만 눈앞의 문장은 그 익숙한 길 대신 다른 길을 가리키고 있었다.
루나는 솔라의 화면을 들여다보는 대신, 책상 위 메모지 한 장을 가져왔다.
“잠깐 라우터는 잊어보자. 여기 하얀 종이가 한 장 있어. 그리고 솔라, 네가 이 종이에 글씨를 쓸 수 있는 유일한 사람이야. 이 종이가 App.jsx라고 생각해 봐.”
루나는 메모지 위에 펜으로 네모를 하나 그렸다.
“만약 내가 이 App.jsx 종이에 ‘목록’이라고 적으면, 사용자에게 뭘 보여줘야 할까?”
“음… 책 목록 페이지?”
“맞아. 그럼 내가 ‘목록’을 지우고, 그 자리에 ‘상세’라고 다시 쓰면?”
“상세 페이지가 나와야지.”
솔라는 너무나 당연한 질문이라는 듯 답했다. 루나는 고개를 끄덕이며 덧붙였다.
“새 책을 등록하고 싶으면? 내가 ‘등록’이라고 쓰면, ‘등록 폼’이 나타나겠지. 여기서 중요한 건 딱 하나야. 이 종이에 지금 뭐라고 쓰여 있느냐. 그게 지금 보여줄 화면을 결정하는 유일한 규칙인 거지.”
루나는 펜으로 메모지 중앙을 톡톡 두드렸다.
“React에서 이렇게 하나의 값에 따라 보이는 내용이 달라지는 걸 뭐라고 부르지? 딱 하나의 값을 기억하고, 그 값이 바뀌면 화면을 다시 그리는 역할.”
”…상태(state)?”
순간 솔라의 눈이 동그래졌다. 너무나 기초적인 개념이라 오히려 생각하지 못했던 부분이었다. useState로 만든 변수 하나. 그 변수의 값에 따라 어떤 컴포넌트를 보여줄지 결정하는 if문이나 삼항 연산자.
“그럼 설명용 이름으로 표현하면 App.jsx 안에 const [currentPage, setCurrentPage] = useState('list') 같은 상태가 있다는 말이구나. 그리고 currentPage가 ‘list’면 BookListPage를, ‘detail’이면 BookDetailPage를 보여주는 식으로 조건부 렌더링을 하는 거고.”
솔라는 스스로 그림을 완성해 나갔다. 라우터가 URL 주소를 읽고 그에 맞는 컴포넌트를 찾아주는 외부 교통경찰이라면, 이 방식은 App.jsx라는 관제탑이 내부 신호등(state)을 바꿔서 교통 흐름(화면)을 직접 통제하는 것과 같았다. URL은 바뀔 필요조차 없을 수 있었다.
“아…”
솔라는 탄식과 함께 무릎을 쳤다.
“이제야 그 문장이 이해되네. ‘라우터 중심이 아니라 App.jsx의 상태 전환으로 흐름을 읽으라’는 게 바로 그 뜻이었구나. 화면 전환의 열쇠는 주소창이 아니라 App.jsx가 쥐고 있었던 거야.”
복잡하게 얽혀 있던 길이 단순하고 명확한 하나의 제어 지점으로 모이는 느낌이었다. 여러 화면은 더 이상 독립적인 섬이 아니라, App.jsx라는 부모의 손짓에 따라 나타나고 사라지는 그림자 인형들이었다. 솔라는 가이드 문장을 다시 읽었다. 아까와 달리, 문장은 더 이상 낯선 길이 아니었다. 오히려 프로젝트 전체 구조를 꿰뚫는 가장 정확한 지도처럼 보였다.
판단이 바뀌자, 곧바로 새로운 궁금증이 고개를 들었다.
“좋아, App.jsx가 관제탑이라는 건 알겠어. 그런데 자식 컴포넌트에서 일어난 일을 관제탑은 어떻게 알아? 예를 들어 BookListPage에서 내가 어떤 책 카드를 ‘클릭’했을 때, 그 목록 페이지가 어떻게 부모인 App.jsx한테 ‘이제 상세 페이지로 바꿔주세요! 그리고 이 책을 보여줘야 해요!’라고 신호를 보낼 수 있는 거지?“
2장: 목록에서 상세로: App.jsx State와 Props Callback의 첫 만남
솔라가 남긴 질문의 무게가 방 안 공기 속에 떠다니는 듯했다. ‘자식은 부모에게 어떻게 신호를 보내는가?’ 그 질문에 답하듯, 루나는 아무 말 없이 어제의 그 메모지를 다시 가져왔다. ‘App.jsx’라고 이름 붙은 그 종이 위에는 여전히 ‘목록’이라는 글씨가 쓰여 있었다.
루나는 말없이 메모지 귀퉁이에 작은 포스트잇을 하나 더 붙였다. 그리고 그 위에 ‘선택된 책 ID: ?’ 라고 적었다. 이제 App.jsx 메모지는 두 가지 정보를 가지게 되었다. 어떤 페이지를 보여줄지와, 어떤 특정 책을 가리키고 있는지. 이 작은 변화가 솔라가 던진 질문의 핵심을 정조준하고 있었다. 상세 페이지를 보여주려면, 단순히 ‘상세’라는 화면 이름뿐만 아니라 ‘어떤 책의’ 상세인지도 알아야만 했다.
솔라는 그 포스트잇을 보자마자 의도를 알아차렸다.
“아, 상태가 하나 더 필요한 거구나. currentPage 같은 상태 말고도, 내가 어떤 책을 눌렀는지 기억할 selectedBookId 같은 상태 말이야. 목록 페이지에서 3번 책을 누르면, App.jsx의 selectedBookId를 3으로 바꾸고, currentPage는 ‘detail’로 바꾸면 되겠네.”
스스로 답을 찾아낸 것 같아 뿌듯해하는 것도 잠시, 솔라의 표정은 다시 원래의 미궁으로 돌아왔다.
“그런데 여전히 모르겠어. 그건 App.jsx 안에서 일어나는 결과잖아. 애초에 BookListPage가 어떻게 App.jsx한테 ‘저 3번 책 눌렸어요!’ 라고 말을 거냐는 거야. 자식이 부모의 상태를 직접 수정할 수는 없잖아.”
React의 기본 원칙, 데이터는 위에서 아래로 흐른다는 단방향 데이터 흐름이 솔라의 발목을 잡고 있었다. 자식은 부모가 주는 props를 받을 뿐, 거슬러 올라가 부모의 state를 제멋대로 바꿀 수 없었다. 마치 관제탑의 지시를 받는 비행기가 마음대로 관제탑의 스크린을 조작할 수 없는 것과 같았다.
“맞아. 직접 바꿀 수는 없어.”
루나는 솔라의 말을 긍정하며, 펜과 함께 작은 메모지 조각을 하나 더 솔라 앞에 건넸다. 이번 것은 아무것도 쓰여있지 않은 백지였다.
“이건 특수한 요청서야. 솔라, 너는 BookListPage의 역할을 맡아봐. 네 앞에는 여러 책 카드가 있고, 그 중 하나를 클릭할 수 있어. 하지만 내 App.jsx 메모지에 직접 손댈 수는 없어. 대신, 이 요청서에 네가 선택한 책의 ID 숫자 하나만 적어서 나에게 다시 건네줄 수는 있어.”
루나는 App.jsx 메모지를 가리켰다. “내가 이 요청서를 너에게 미리 건네줬다는 게 중요해. 나는 이 요청서가 돌아오면 무엇을 할지 이미 정해뒀어. 바로 ‘선택된 책 ID’를 네가 적어준 숫자로 바꾸고, 현재 화면을 ‘상세’로 바꾸는 거지.”
솔라는 잠시 생각에 잠겼다. 자식은 부모의 상태를 직접 바꾸지 못한다. 하지만 부모가 ‘내 상태를 바꿀 수 있는 방법이 담긴 함수’ 자체를 자식에게 props로 내려준다면?
”…아! 그럼 그 요청서가 바로 함수(function)구나! App.jsx에서 handleSelectBook 같은 함수를 만들어서, 그걸 BookListPage에게 onSelectBook이라는 props로 전달하는 거야.”
솔라의 목소리에 확신이 차기 시작했다. 머릿속에서 코드 조각들이 제자리를 찾아 맞춰졌다.
- 부모 (
App.jsx):const [selectedBookId, setSelectedBookId] = useState(null)같은 상태와,const handleSelectBook = (id) => { setSelectedBookId(id); setCurrentPage('detail'); }이라는 함수를 정의한다. - 전달:
<BookListPage onSelectBook={handleSelectBook} />처럼, 자식에게 함수를props로 내려준다. - 자식 (
BookListPage): 책 카드를 클릭하면, 부모에게 받은props인onSelectBook함수를 실행한다.onClick={() => props.onSelectBook(3)}이런 식으로.
“그럼 BookListPage는 onSelectBook이 정확히 무슨 일을 하는 함수인지는 전혀 몰라도 되는 거네! 그냥 ‘사용자가 책을 골랐으니 이 신호를 보내주세요’ 하고 props로 받은 벨을 누르기만 하면 되는구나. 벨을 누르면 관제탑에서 무슨 일이 일어날지는 관제탑만 알면 되고.”
이것이 바로 props callback의 본질이었다. 자식이 부모의 상태 변경을 유발해야 할 때, 부모가 상태를 변경하는 ‘리모컨’을 자식에게 빌려주는 것. 자식은 그저 버튼을 누를 뿐, 그 신호가 정확히 어떤 변화를 일으키는지는 부모의 설계에 달려 있었다.
솔라는 가이드 문장을 다시 떠올렸다. ‘App.jsx는 현재 화면을 나타내는 state와 선택된 book id를 기준으로 list/detail/form 흐름을 전환한다.’ 이제야 이 문장이 구체적인 코드로 보였다. 설명용으로 붙인 currentPage와 selectedBookId 같은 상태가 화면과 선택 대상을 기억하고, 흐름을 ‘전환’시키는 계기는 자식으로부터 온 props callback 신호였다.
목록에서 상세로 가는 길이 명확하게 뚫리는 순간이었다. 하지만 솔라가 BookShelf 프로젝트의 전체 흐름도를 다시 살펴보자, 새로운 길이 눈에 들어왔다.
“잠깐, 목록에서 상세로 가는 건 이제 알겠어. 그런데 상세 페이지(BookDetailPage)에 들어가면 ‘수정하기’ 버튼이 있네. 이걸 누르면 폼 페이지(BookFormPage)로 가. 그리고 거기서 저장을 누르면… 다시 목록 페이지로 돌아와야 하잖아? 이건 그냥 한 단계가 아닌데. 이 복잡한 왕복 여행도 전부 App.jsx의 상태와 콜백만으로 관리하는 거야?“
3장: CRUD 완결: 다단계 화면 흐름과 데이터 연결 패턴
솔라의 질문은 이미 해답의 일부를 품고 있었다. 이제 솔라는 책상 위에 펼쳐진 흐름도를 보며, 목록에서 상세로 가는 외길이 아니라 복잡한 교차로를 마주하고 있었다. 상세 페이지(BookDetailPage)에서 시작해 수정 폼(BookFormPage)을 거쳐, 다시 목록(BookListPage)으로 돌아오는 여정. 솔라는 이 ‘왕복 여행’을 어떻게 구현할지 가늠해 보려는 듯, App.jsx라고 쓰인 메모지 옆에 새로운 경로를 그려보기 시작했다.
그녀는 작은 포스트잇 세 장을 떼어냈다. 첫 장에는 ‘상세’, 둘째 장에는 ‘폼’, 셋째 장에는 ‘목록’이라고 적었다. 그리고는 어제 루나가 했던 것처럼, 각 포스트잇을 상태(currentPage)의 변화라고 생각하며 순서대로 놓아 보았다. ‘상세’에서 ‘폼’으로 가는 화살표를 그리고, ‘폼’에서 ‘목록’으로 돌아오는 화살표를 그렸다. 간단한 편도 여행이 아니라, 떠났다가 다른 곳을 거쳐 출발점으로 돌아오는 순환 경로였다.
“음, ‘상세’에서 ‘수정’ 버튼을 누르면 App.jsx의 currentPage를 ‘form’으로 바꾸는 콜백을 호출하면 되겠지. 이건 이제 알겠어.”
솔라는 ‘상세’에서 ‘폼’으로 가는 화살표를 톡톡 두드렸다.
“그리고 ‘폼’에서 ‘저장’ 버튼을 누르면, currentPage를 다시 ‘list’로 바꾸는 콜백을 부르면 되고. 그럼 다시 목록 화면으로 돌아올 거야. 간단하네.”
하지만 말을 내뱉자마자 솔라 스스로의 표정에 의문이 어렸다. 무언가 중요한 것을 빠뜨린 느낌이었다. 그녀는 자신이 그린 흐름도를 가만히 노려보았다. 책의 제목을 ‘리액트의 정석’에서 ‘리액트의 모든 것’으로 수정하고 ‘저장’을 눌렀다고 가정해 보자. 흐름도에 따르면, 화면은 분명 목록 페이지로 돌아갈 것이다.
”…그런데 잠깐. 목록으로 돌아가면, 방금 내가 수정한 내용이 반영되어 있어야 하잖아? ‘리액트의 모든 것’으로 바뀐 제목이 보여야지. 그냥 currentPage만 ‘list’로 바꾸면, App.jsx가 가지고 있던 원래 책 목록 데이터를 그대로 보여줄 테니… 수정 전의 제목인 ‘리액트의 정석’이 그대로 보이겠네.”
솔라는 허점을 발견했다. 각 화면이 서로 데이터를 주고받는 게 아니라는 건 이제 안다. 모든 데이터는 App.jsx가 중앙에서 관리한다. 그렇다면 BookFormPage는 어떻게 App.jsx에게 “화면만 바꾸지 말고, 이 데이터로 원래 책 목록 정보도 업데이트해주세요!”라고 말할 수 있을까? 단순히 상태 변경만으로는 데이터의 흐름까지 해결되지 않았다. 화면들은 여전히 각자의 역할만 하는 독립적인 섬처럼 느껴졌다.
“바로 그 지점이야.”
솔라의 혼잣말을 듣고 있던 루나가 조용히 입을 열었다. 그녀는 이전 장에서 솔라에게 건넸던 ‘요청서’ 비유를 다시 가져왔다.
“자식 컴포넌트가 부모에게 보내는 요청서는 한 종류가 아니야. BookListPage가 보냈던 ‘이 ID의 책을 보여주세요’라는 요청서가 있었지. 그럼 BookFormPage가 ‘저장’ 버튼을 눌렀을 때 보내야 하는 요청서에는 무엇이 담겨야 할까? ‘목록으로 돌아가세요’라는 메시지면 충분할까?”
솔라는 BookFormPage의 입장이 되어 보았다. 사용자가 양식을 다 채우고 ‘저장’을 눌렀다. 나는 이제 부모인 App.jsx에게 일을 다 마쳤다고 보고해야 한다.
“아니, 충분하지 않아. ‘목록으로 돌아가세요’라는 메시지랑… 내가 수정한 책의 최종 데이터 덩어리 전부를 같이 보내야 해!”
유레카를 외치듯 솔라의 목소리가 커졌다.
“그렇구나! BookFormPage는 onSave 같은 이름의 콜백 함수를 props로 받는데, 이 함수는 그냥 호출하는 게 아니라 onSave(updatedBookData)처럼 수정된 책 객체를 인자로 넘겨주는 거야. 그럼 App.jsx에 있는 handleSave 함수는 그 객체를 받아서, 자신의 메인 books 상태 배열을 업데이트하고, 그 다음에 setCurrentPage('list')를 실행하는 거지!”
이제야 모든 조각이 맞춰졌다. props callback은 단순한 ‘신호’가 아니라, 필요한 ‘데이터’를 싣고 부모에게 돌아가는 정교한 메커니즘이었다. 사용자 조작의 단계마다 각기 다른 내용물이 담긴 요청서가 오고 가는 셈이었다.
- 상세 → 폼:
BookDetailPage에서 ‘수정’ 버튼 클릭.onEdit()콜백 호출.App.jsx는setCurrentPage('form')을 실행.selectedBookId는 이미 있으므로 어떤 책을 수정할지 안다. - 폼 작업:
App.jsx는selectedBookId에 해당하는 책 데이터를BookFormPage에props로 내려준다. 폼은 그 데이터로 채워진다. - 폼 → 목록 (데이터와 함께):
BookFormPage에서 ‘저장’ 버튼 클릭.onSave(updatedBookData)콜백 호출.App.jsx는 이updatedBookData로books상태를 업데이트한 뒤,setCurrentPage('list')와setSelectedBookId(null)을 실행하여 상태를 초기화한다.
이 다단계 흐름의 제어 패턴을 깨닫자, 솔라는 더 이상 BookShelf의 화면 전환이 복잡하게 느껴지지 않았다. 오히려 명확하고 예측 가능한 규칙들의 연쇄 반응으로 보였다. App.jsx라는 중앙 관제탑이 각기 다른 상황에 맞는 여러 종류의 ‘리모컨 버튼’(props callback)을 자식들에게 나눠주고, 자식들은 상황에 맞는 버튼을 눌러 데이터를 실어 보내는 아름다운 협업이었다.
판단이 바뀌자, 솔라는 이제 분석가가 아닌 설계자의 눈으로 프로젝트를 보기 시작했다. 그녀는 책상 한켠에 있던 깨끗한 메모지를 가져와 ‘새 책 추가하기’ 흐름을 스스로 설계해보기로 했다. 이것은 지금까지 분석한 ‘수정’ 흐름과는 또 다른 도전이었다.
솔라는 펜으로 차분하게 사용자의 여정을 따라가며 App.jsx의 상태 변화와 콜백을 써 내려갔다.
시나리오: 새 책 추가
- 사용자:
BookListPage에서 ‘새 책 추가’ 버튼 클릭.BookListPage: 부모에게 받은onAddNewBook()콜백 호출. (인자는 필요 없음)App.jsx:handleAddNewBook함수 실행.setSelectedBookId(null),setCurrentPage('form')설정.BookFormPage:selectedBookId가 없으므로 빈 폼을 렌더링.- 사용자: 폼 작성 후 ‘저장’ 버튼 클릭.
BookFormPage:onSave(newBookData)콜백 호출.App.jsx:handleSave함수 실행. 인자로 받은newBookData에 ID가 없는 것을 확인하고, 새 ID를 부여한 후books배열에 추가. 그 후setCurrentPage('list')실행.- 결과: 사용자는 새 책이 추가된 목록 화면을 보게 된다.
스스로 완성한 작은 설계도를 보며 솔라는 미소 지었다. 더 이상 App.jsx의 상태 전환이나 props callback은 막연한 개념이 아니었다. 그것은 화면과 데이터의 흐름을 엮어내는 구체적이고 강력한 도구였다. 라우터가 없다는 사실에 의문을 품었던 처음의 자신을 떠올리자, 이제는 그 선택이 얼마나 이 프로젝트의 구조를 명확하게 만드는지 이해할 수 있었다. 모든 길은 App.jsx로 통하고, 모든 신호는 App.jsx에서 시작되거나 그곳으로 수렴했다. 그 손 안에, 프로젝트의 모든 흐름이 명료하게 담겨 있었다.