Frontend 31

게시판 진화로 보는 프론트엔드-서버 아키텍처 연결

여러 장에서 배운 HTML, DOM, React, state, fetch가 결국 한 아키텍처 안에서 어떻게 이어졌는지 정리가 필요하다.

근거 · 교안 p251-p254

게시판 진화로 보는 프론트엔드-서버 아키텍처 연결 대표 이미지

1장: 정적인 게시판 뼈대: HTML/CSS로 시작하기

솔라의 코드 편집기 화면은 거의 텅 비어 있었다. 깜빡이는 커서 옆으로 index.html이라는 파일명만 덩그러니 놓여 있었다. 솔라는 며칠 전 강의 노트에서 봤던 한 문장을 떠올리며 미간을 찌푸렸다. ‘게시판은 정적 화면에서 서버와 대화하는 앱으로 진화했다.’

진화. 거창한 단어였다. 단어의 뜻은 알지만, 그 과정의 첫 장면이 그려지지 않았다. 마치 유인원이 갑자기 현대인이 된 것처럼, 그 사이의 수많은 단계가 통째로 증발한 느낌이었다. 모든 웹 애플리케이션은 처음부터 복잡한 상호작용을 전제로 만들어지는 것만 같았다.

“언니.”

솔라가 등 뒤에서 책을 읽던 루나를 불렀다.

“게시판이 ‘진화’했다는 건 알겠어. 근데 맨 처음은 뭐였을까? 아예 처음. 아무것도 없는 상태에서 제일 먼저 만드는 거 말이야. 당연히 처음부터 버튼 누르면 글이 써지고 그러진 않았을 거 아냐. 근데 그 이전이 상상이 안 가. 그냥 하얀 화면?”

솔라의 질문에는 막연함이 묻어 있었다. ‘앱’이라면 당연히 동적인 기능을 가져야 한다는 생각, 그렇지 않다면 그저 무의미한 빈 페이지일 뿐이라는 고정관념이 느껴졌다.

루나는 읽던 책을 조용히 덮고 솔라의 옆으로 다가왔다. 솔라의 텅 빈 index.html 파일을 잠시 바라보던 루나가 말했다.

“진화의 시작. 그럼 우리 손으로 직접 화석을 하나 만들어 볼까? 모든 기능이 사라진 가장 원시적인 형태의 게시판.”

“화석?”

“응. 지금 솔라 네가 상상하는 모든 ‘기능’을 빼고, 화면에 남아야 할 ‘정보’만 생각해 봐. 우리가 만들려는 게 게시판이니까, 게시판에 꼭 있어야 하는 정보가 뭐지?”

루나의 말에 솔라는 잠시 고민했다. “음… 일단 글 목록이 있겠고. 글 하나에는 제목, 내용, 작성자 같은 게 있어야지.”

“좋아. 그걸 그대로 이 문서에 적어보는 거야. 마치 컴퓨터가 아니라, 그냥 사람이 읽을 문서를 작성하듯이.”

솔라는 키보드 위로 손을 가져갔다. 아직 반신반의하는 표정이었다. 이게 정말 게시판의 시작일까. 솔라는 루나의 말대로, 마치 보고서를 쓰듯 게시물의 구조를 생각하며 코드를 입력하기 시작했다.

<div>
  <h3>첫 번째 게시물 제목</h3>
  <p>이것은 첫 번째 게시물의 내용입니다.</p>
  <span>작성자: 솔라</span>
</div>

하나의 게시물을 나타내는 덩어리가 만들어졌다. 솔라는 방금 만든 코드를 그대로 복사해서 두 번 더 붙여넣었다. 제목과 내용만 조금씩 바꿨다. 화면에는 순서 없이 뒤섞인 텍스트만 나타났다. 여기에 약간의 CSS를 추가해 각 게시물 div에 테두리를 그리고, 제목과 내용의 글자 크기를 조절했다.

그러자 놀랍게도 그럴듯한 ‘게시판 목록’의 형태가 화면에 나타났다.

솔라는 마우스를 움직여 화면을 이리저리 눌러보았다. 물론 아무 일도 일어나지 않았다. 버튼도, 링크도 없었다. 그냥 보기 좋게 정돈된 텍스트 뭉치일 뿐이었다.

“이게… 다야?” 솔라가 허탈한 듯 물었다.

“새로고침해봐.” 루나가 짧게 말했다.

솔라는 브라우저의 새로고침 버튼을 눌렀다. 당연히 아무것도 변하지 않았다. 늘 똑같은 3개의 게시물이 그 자리에 그대로 있었다.

“여기에 새 글을 추가하려면 어떻게 해야 해?”

솔라의 질문에 루나는 대답 대신, 솔라가 방금 작성한 index.html 파일을 가리켰다. 솔라는 그제야 깨달았다. 새로운 글을 추가하려면, 이 HTML 파일 자체를 열어서 네 번째 div 덩어리를 직접 타이핑해야만 했다. 게시물을 지우고 싶어도, 수정하고 싶어도 마찬가지였다. 이 웹페이지의 내용을 바꾸는 유일한 방법은 소스 코드를 직접 수정하는 것뿐이었다.

그 순간, 솔라의 머릿속에서 무언가 연결되는 느낌이 들었다.

“아…!”

솔라가 나지막이 탄성을 내뱉었다.

“알겠다. 처음엔 그냥 이렇게 생긴 ‘문서’였구나. 어떤 기능도 없이, 그냥 ‘이런 정보가 있다’고 보여주기만 하는. 우리가 만든 건 ‘앱’이 아니라, 뼈대와 옷만 겨우 갖춰 입은 정적인 ‘전시품’ 같은 거네.”

‘진화’라는 거대한 단어의 첫걸음이 드디어 눈에 보였다. 그것은 복잡한 기능이 아니라, 가장 단순한 형태의 구조, 바로 정적인 뼈대에서 시작되었다. 어떤 웹 서비스든 그 시작은 동적인 기능이 아니라, 내용을 담는 정적인 구조라는 사실을 깨달은 것이다. 웹 페이지는 처음부터 동적이어야 한다는 막연한 생각이 깨지는 순간이었다.

솔라는 자신이 만든 정적인 게시판 화면을 뿌듯하게 바라보았다. 이제 시작점이 어디인지 확실히 알 것 같았다. 하지만 동시에 새로운 궁금증이 고개를 들었다.

솔라는 가만히 멈춰 있는 화면 위로 마우스 커서를 가져가 의미 없이 흔들었다.

“좋아, 뼈대는 만들었어. 그럼 이제… 이 뼈대에 어떻게 살을 붙이고 피를 돌게 하지? 이 정적인 화면 위에서 내가 버튼을 ‘클릭’했을 때, 뭔가 반응하게 만들려면 대체 어디서부터 시작해야 하는 걸까?“

2장: 생명을 불어넣는 바닐라 JS: DOM과 이벤트

솔라의 코드 편집기 화면에 새로운 태그 한 줄이 추가되었다. 어제 완성한 정적 게시판의 첫 번째 게시물 div 안쪽에, 그녀는 방금 <button>좋아요</button>라는 코드를 입력했다. ‘반응’을 만들어내기 위한 가장 직관적인 시도였다. 이 정적인 구조물에 어떻게든 생명을 불어넣고 싶다는 조급함이 낳은, 즉흥적인 실험이었다.

파일을 저장하고 브라우저를 새로고침하자, ‘첫 번째 게시물’ 제목 아래로 작은 ‘좋아요’ 버튼이 얌전히 자리 잡았다. 하지만 솔라가 그 버튼을 아무리 클릭해도 화면에서는 어떤 변화도 감지되지 않았다. 마우스 클릭 소리만이 방 안을 공허하게 울릴 뿐이었다. HTML은 버튼을 만들어주었지만, 그 버튼에 영혼을 불어넣지는 못했다. 이것은 마치 설명서만 있고 동력 장치가 없는 장난감과 같았다.

“그 버튼, 눌렀을 때 뭘 하고 싶어?”

어느새 다가온 루나가 모니터를 보며 물었다.

“음… 일단은 뭐라도 바뀌었으면 좋겠어. 예를 들면, 이 게시물 제목을 ‘좋아요 누른 게시물!’이라고 바꾼다든지.”

“HTML은 구조를, CSS는 모양을 정의하는 언어지. 하지만 ‘클릭했을 때’라는 조건과 ‘제목을 바꾼다’는 동작은 그것들의 역할이 아니야. 브라우저가 알아들을 수 있는 행동의 언어가 필요해.”

루나는 솔라의 index.html 파일 맨 아래에 새로운 태그 한 줄을 추가했다. <script src="app.js"></script>. 그리고는 app.js라는 이름의 빈 파일을 새로 만들었다.

“이게 행동의 언어를 담을 곳이야. 자바스크립트(JavaScript).”

솔라는 루나의 안내에 따라 app.js 파일에 함수를 하나 작성했다.

function changeTitle() {
  // 여기에 제목을 바꾸는 코드를 넣을 거야.
}

“함수는 약속된 동작의 묶음이야. 이제 ‘좋아요’ 버튼을 눌렀을 때, 이 changeTitle이라는 약속된 동작이 실행되도록 연결해 줘야 해.”

루나는 다시 index.html 파일의 버튼 태그를 수정했다.

<button onclick="changeTitle()">좋아요</button>

onclick. ‘클릭했을 때’. 이름만으로도 그 역할이 명확하게 느껴졌다. 솔라는 이제 마지막 퍼즐 조각을 맞춰야 했다. changeTitle 함수 안에서, 어떻게 ‘첫 번째 게시물 제목’을 찾아 바꿀 수 있을까.

“자바스크립트에게 HTML 문서를 뒤져서 ‘첫 번째 게시물 제목’을 찾아오라고 시켜야 해. 마치 심부름을 시키는 것처럼. 자바스크립트는 브라우저가 HTML 문서를 해석해서 만든 설계도, 즉 **DOM(Document Object Model)**을 보고 요소를 찾아낼 수 있어.”

루나의 설명에 따라 솔라는 app.js 파일의 비어있던 함수를 채워 넣었다.

function changeTitle() {
  const firstPostTitle = document.querySelector('h3');
  firstPostTitle.textContent = '좋아요 누른 게시물!';
}

document.querySelector('h3'). ‘이 문서(document)에서 h3 태그를 찾아줘(querySelector)’. 너무나 직설적인 명령이었다. 솔라는 떨리는 마음으로 파일을 저장하고 브라우저를 새로고침했다. 그리고 다시, ‘좋아요’ 버튼 위로 마우스 커서를 가져가 클릭했다.

찰칵.

‘첫 번째 게시물 제목’이라고 쓰여 있던 글자가 순식간에 ‘좋아요 누른 게시물!’로 바뀌었다.

“와…!”

솔라의 입에서 짧은 탄성이 터져 나왔다. 처음이었다. 자신의 ‘행동’에 웹 페이지가 ‘반응’한 것은. 정적인 전시품에 드디어 생명이 깃든 순간이었다. HTML이 제공한 뼈대(button, h3) 위에서, 사용자의 행동(onclick 이벤트)을 신호탄 삼아, 자바스크립트가 DOM이라는 지도를 보고 달려가 내용을 바꿔치기한 것이다. 이 모든 과정이 눈앞에서 완벽하게 연결되었다.

정적인 페이지에 클릭과 같은 사용자 반응을 추가할 수 있게 된 것이다. 이것이 게시판 진화의 두 번째 단계, 바로 사용자 동작을 제어하는 힘이었다. 솔라는 신이 나서 코드를 조금 더 만져보았다. 다른 버튼을 만들어 게시물 내용을 숨기거나 보이게도 해보고, 게시물 자체를 삭제하는 기능도 흉내 내 보았다. document.createElement로 새로운 HTML 요소를 만들어 붙이는 것도 가능했다.

하지만 몇 분간의 흥분과 실험이 끝나자, 새로운 고민이 스멀스멀 피어올랐다.

“언니, 이거… 게시물이 세 개니까 망정이지, 만약 백 개라면? 백 개의 게시물에 각각 ‘좋아요’ 기능을 붙이려면, 이 changeTitle 같은 함수를 백 개 만들어야 해? 아니면 엄청 복잡한 조건문을 써야 하나? 새 글을 추가하는 기능은 더 끔찍한데. document.createElement('div'), createElement('h3')… 이렇게 하나하나 만들어서 붙이는 건 너무 복잡하고 실수하기도 쉬울 것 같아.”

솔라의 app.js 파일은 벌써부터 뒤죽박죽이 될 조짐을 보이고 있었다. 한낱 제목 하나 바꾸는 데도 이렇게 여러 줄의 코드가 필요한데, 수많은 기능이 얽힌 진짜 게시판을 만든다면 코드는 걷잡을 수 없이 엉망이 될 것 같았다. 바닐라 자바스크립트로 동적인 제어는 가능해졌지만, 동시에 ‘복잡성’이라는 새로운 그림자가 드리우기 시작했다.

3장: 복잡성을 다루는 React: 컴포넌트 아키텍처

솔라의 app.js 파일은 더 이상 간단한 실험장이 아니었다. 지난밤의 흥분 속에서 이것저것 기능을 추가해 본 결과, 파일은 금세 100줄을 넘어섰다. 화면에는 세 개의 게시물이 있었고, 자바스크립트 파일에는 그 세 개의 게시물을 각각 제어하기 위한 코드 덩어리들이 위태롭게 얽혀 있었다. 솔라는 나름의 질서를 부여하겠다며 주석으로 경계선을 그어 놓았다. // --- 게시물 1번 UI 및 이벤트 처리 ---, // --- 게시물 2번 UI 및 이벤트 처리 --- 같은 식이었다. 하지만 그건 임시방편일 뿐, 코드를 한눈에 파악하기는 점점 더 어려워졌다.

새로운 기능을 추가하는 것은 고통에 가까웠다. 게시물마다 ‘삭제’ 버튼을 추가하려다 두 번이나 포기했다. 각 게시물의 HTML 구조와 그에 연결된 자바스크립트 함수들이 너무 단단하게 짝지어져 있어서, 하나를 건드리면 다른 곳에서 오류가 터져 나왔다. 마치 엉킨 실타래에서 특정 색깔의 실 한 가닥만 쏙 뽑아내려는 시도처럼 무모하게 느껴졌다. 이 방식으로는 게시물 열 개짜리 게시판도 만들기 전에 코드가 무너져 내릴 것 같았다.

“언니, 이건… 아닌 것 같아.”

솔라는 결국 키보드에서 손을 떼고 의자 등받이에 몸을 깊게 기댔다. 그녀의 목소리에는 동적인 제어의 기쁨을 처음 맛봤을 때의 흥분 대신, 예기치 못한 복잡성 앞에서 길을 잃은 막막함이 묻어났다.

“분명히 자바스크립트로 HTML을 조작할 수 있다는 건 알겠어. 그런데 게시물이 늘어날수록 코드가 엉망이 돼. 게시물 하나가 자기 HTML 구조랑, 자기 ‘좋아요’ 버튼 동작이랑, 자기 ‘삭제’ 버튼 동작을 전부 다 따로 가지고 있는데, 이 모든 게 app.js라는 파일 하나에 뒤죽박죽 섞여 있어. 이걸 어떻게 깔끔하게 관리하지?”

루나는 솔라의 화면에 그려진 위태로운 주석 경계선들을 잠시 바라보았다. 그리고는 말없이 솔라의 책상 위를 손으로 가리켰다. 연필과 펜이 담긴 연필꽂이, 작은 메모지들이 담긴 플라스틱 함, 클립 몇 개가 담긴 자석 홀더가 보였다.

“솔라 네가 책상을 정리하는 방식이랑 똑같아. 연필은 연필끼리, 클립은 클립끼리 모아두잖아. 모든 걸 그냥 책상 서랍 하나에 쏟아붓지 않고. 코드도 마찬가지야. 서로 관련 있는 것끼리 하나의 ‘상자’에 담아두면 어떨까?”

“상자?”

“응. ‘게시물’이라는 상자. 그 상자 안에는 게시물의 모양을 정의하는 HTML 구조, 그리고 그 안에서 일어나는 ‘좋아요’나 ‘삭제’ 같은 행동을 처리하는 자바스크립트 기능이 함께 들어있는 거지.”

루나는 솔라의 프로젝트에 PostItem.js라는 새 파일을 만들었다. 그리고는 솔라가 app.js에서 힘겹게 document.createElement로 만들던 게시물 한 개의 모습을, 전혀 다른 방식으로 작성하기 시작했다.

function PostItem() {
  return (
    <div>
      <h3>첫 번째 게시물 제목</h3>
      <p>이것은 첫 번째 게시물의 내용입니다.</p>
      <button>좋아요</button>
    </div>
  );
}

솔라는 눈을 동그랗게 떴다. “어? 이건… 자바스크립트 파일인데, 안에 HTML이 그대로 들어있네?”

“정확히는 HTML이 아니라 JSX라는 문법이야. 자바스크립트를 확장한 건데, 이렇게 자바스크립트 안에서 UI의 구조를 직관적으로 표현할 수 있게 해주지. 이 PostItem이라는 함수가 바로 우리가 말한 ‘게시물’ 상자, 즉 **컴포넌트(Component)**야.”

루나는 말을 이었다. “이제 이 PostItem이라는 부품을 조립해서 게시판을 만들어 볼 거야.”

그녀는 기존의 복잡했던 app.js 파일의 내용을 대부분 지우고, 간결한 코드를 새로 작성했다.

import PostItem from './PostItem.js';

function App() {
  return (
    <div>
      <h1>나의 게시판</h1>
      <PostItem />
      <PostItem />
      <PostItem />
    </div>
  );
}

솔라는 그 코드를 보고 순간 숨을 멈췄다. 이전의 app.js에서 수십 줄에 걸쳐 querySelectorcreateElement, appendChild를 반복하며 구현했던 지저분한 코드가 아니었다. 그냥 <PostItem />이라는, 마치 새로운 HTML 태그 같은 것을 세 번 연달아 쓴 게 전부였다.

브라우저 화면에는 이전과 똑같이 세 개의 게시물이 나타났다. 하지만 내부의 구조는 완전히 달라져 있었다.

“아…!”

솔라의 입에서 탄성이 터져 나왔다. 깨달음의 순간이었다.

“이건… 이건 조립이잖아! PostItem이라는 재사용 가능한 부품을 미리 하나 만들어두고, 필요할 때마다 가져다 쓰는 거네! 만약 게시물 디자인을 바꾸고 싶으면, app.js를 건드릴 필요 없이 PostItem.js 파일 하나만 수정하면 되는 거고.”

그녀는 자신이 가졌던 생각이 얼마나 얕았는지 깨달았다. React는 단순히 HTML을 더 멋지게 꾸미는 도구가 아니었다. 그것은 복잡한 UI를 다루는 방식, 즉 아키텍처에 대한 완전히 새로운 제안이었다. 무질서하게 얽혀 있던 코드들을 ‘컴포넌트’라는 독립적이고 재사용 가능한 단위로 묶어 관리하는 것. 마치 찰흙으로 주먹구구식 조각을 만들던 방식에서, 기능과 모양이 규격화된 레고 블록으로 거대한 성을 쌓는 방식으로 진화한 것과 같았다.

복잡성을 관리하는 열쇠는 바로 이 ‘재사용 가능한 UI 단위’에 있었다.

솔라는 방금 자신이 깨달은 사실에 흥분하며 키보드 위로 손을 가져갔다. 이제 게시물 백 개를 만드는 것도 두렵지 않았다. <PostItem />을 백 번 쓰거나, 반복문을 사용하면 될 터였다.

“굉장하다! 이제 진짜 게시판처럼 만들 수 있겠어. 그런데 언니,”

솔라는 화면에 똑같은 내용으로 복제된 세 개의 게시물을 번갈아 보며 고개를 갸웃했다.

“부품을 재사용하는 건 알겠어. 근데… 이 부품들 내용이 다 똑같잖아. 나는 첫 번째 블록에는 ‘안녕’이라고 쓰고, 두 번째 블록에는 ‘리액트’라고 쓰고 싶은데. 이 PostItem이라는 부품에 어떻게 각기 다른 데이터를 전달해 주지?“

4장: 데이터 흐름을 잇는 React State: 상태 기반 UI

솔라의 App.js 파일은 더 이상 이전과 같지 않았다. 똑같은 내용의 <PostItem /> 태그 세 줄이 있던 자리는, 이제 자바스크립트의 map 함수를 사용하는 코드로 바뀌어 있었다. 그녀는 ‘부품에 각기 다른 데이터를 전달하는’ 문제를 해결하기 위해 스스로 답을 찾아냈다. 부모 컴포넌트인 App에서 게시물 데이터가 담긴 배열을 만들고, 자식 컴포넌트인 PostItem에게 그 데이터를 ‘속성(props)‘으로 내려주는 방식이었다.

// App.js
const postsData = [
  { id: 1, title: '리액트 공부 시작', content: '컴포넌트가 신기하다.' },
  { id: 2, title: 'Props 사용법', content: '데이터를 내려줄 수 있다!' },
  { id: 3, title: '다음 단계는?', content: '이제 뭘 해야 할까?' },
];

function App() {
  return (
    <div>
      <h1>나의 게시판</h1>
      {postsData.map(post => (
        <PostItem key={post.id} title={post.title} content={post.content} />
      ))}
    </div>
  );
}

브라우저 화면에는 드디어 각기 다른 제목과 내용을 가진 세 개의 게시물이 깔끔하게 나타났다. 솔라는 만족스러운 미소를 지었다. 이제 정말 그럴듯한 게시판의 모습이었다. 하나의 PostItem이라는 설계도를 가지고, 각기 다른 재료(props)를 주입해 여러 개의 결과물을 찍어낸 것이다.

“언니, 이거 봐. 이제 게시물마다 다른 내용을 보여줄 수 있어! PostItem은 그냥 껍데기고, 진짜 데이터는 App이 들고 있다가 map으로 하나씩 나눠주는 거였어.”

“부품을 조립하는 단계에서, 이제 부품에 필요한 재료를 공급하는 단계로 넘어온 거네.” 루나가 조용히 고개를 끄덕였다.

솔라는 자신감이 붙었다. 이제 이 게시판에 생명을 불어넣을 다음 단계를 실행해 볼 차례였다. 바로 ‘새 글 추가’ 기능이었다. 그녀는 App.js에 간단한 버튼 하나와 함수를 추가했다.

// ... postsData 배열은 그대로 ...

function App() {
  const handleAddPost = () => {
    const newPost = { id: 4, title: '새로운 글', content: '추가 성공?' };
    postsData.push(newPost);
    console.log('게시물 추가됨:', postsData);
  };

  return (
    <div>
      <h1>나의 게시판</h1>
      <button onClick={handleAddPost}>새 글 추가</button>
      {/* ... map으로 게시물 목록을 보여주는 부분 ... */}
    </div>
  );
}

솔라의 논리는 명확했다. postsData라는 배열이 게시물 목록의 원본 데이터이니까, 그 배열에 새로운 게시물 객체를 push 하기만 하면 된다. 그러면 React가 똑똑하게 변경된 postsData를 감지해서 map 함수를 다시 실행하고, 네 번째 게시물을 화면에 그려줄 것이라고 기대했다. 일반적인 자바스크립트 변수를 다룰 때와 똑같은 생각이었다.

솔라는 브라우저로 돌아가 ‘새 글 추가’ 버튼을 힘차게 눌렀다. 개발자 도구의 콘솔 창에는 ‘게시물 추가됨:‘이라는 메시지와 함께 네 개의 객체가 담긴 배열이 찍혔다. 데이터는 분명히 추가되었다. 하지만 화면에는 아무런 변화도 없었다. 여전히 세 개의 게시물만 덩그러니 놓여 있을 뿐이었다.

“어…?”

솔라는 몇 번이고 버튼을 더 눌러보았다. 콘솔 창의 배열에는 5개, 6개, 7개의 게시물이 쌓여갔지만, 화면은 요지부동이었다. 마치 데이터가 사는 세계와 UI가 사는 세계가 완전히 분리된 것 같았다.

“왜… 왜 안 바뀌지? 배열에는 분명히 새 글이 들어갔는데. React는 이걸 왜 모르는 거야?”

그녀의 목소리에는 당혹감이 가득했다. 컴포넌트라는 멋진 개념을 이해했다고 생각했는데, 가장 기본적인 데이터 변경조차 화면에 반영하지 못하는 상황이 답답했다. React의 state는 그저 평범한 변수와 다를 바 없다는, 그녀의 섣부른 판단이 발목을 잡은 순간이었다.

루나가 솔라의 App.js 코드를 가리켰다. “postsData는 그냥 자바스크립트 배열일 뿐이야. React에게는 수많은 변수 중 하나일 뿐, 특별한 의미가 없어. React는 그 변수의 내용물이 바뀌었는지 일일이 감시하지 않아.”

“감시하지 않는다고? 그럼 어떻게 화면을 바꾸라는 거야?”

“React에게 ‘이 데이터는 특별하니까, 변하면 화면을 새로 그려줘’라고 알려줘야 해. 그냥 변수가 아니라, 화면의 상태를 결정하는 데이터, 즉 State로 만들어야 하는 거지.”

루나는 코드 몇 줄을 수정했다. import { useState } from 'react'; 라는 구문이 파일 상단에 추가되었고, postsData 배열을 선언하던 부분이 완전히 새로운 모습으로 바뀌었다.

// App.js
import { useState } from 'react';

const initialPosts = [
  // ... 이전의 게시물 데이터 3개 ...
];

function App() {
  const [posts, setPosts] = useState(initialPosts); // 이 부분!

  const handleAddPost = () => {
    const newPost = { id: Date.now(), title: '새로운 글', content: '추가 성공!' };
    setPosts([...posts, newPost]); // posts.push() 대신 이것을 사용
  };

  return (
    <div>
      <h1>나의 게시판</h1>
      <button onClick={handleAddPost}>새 글 추가</button>
      {posts.map(post => (
        <PostItem key={post.id} title={post.title} content={post.content} />
      ))}
    </div>
  );
}

useState. ‘상태를 사용한다’. 솔라는 바뀐 코드를 뚫어지라 쳐다보았다. const [posts, setPosts] 라는 기묘한 문법. 그리고 posts.push(newPost) 대신 setPosts([...posts, newPost]) 라는, 조금 더 복잡해 보이는 함수 호출.

“이게… 마법 주문이야?”

“거의. useState는 React에게 ‘지금부터 이 데이터를 관리해줘’라고 등록하는 과정이야. 그러면 React는 두 가지를 돌려줘. 하나는 posts라는 이름의, 읽기 전용인 현재 상태 데이터. 다른 하나는 setPosts라는, 상태를 변경할 수 있는 유일한 ‘함수’지. 이제부터 posts 데이터를 바꾸고 싶으면, 반드시 setPosts 함수를 통해서만 해야 해.”

솔라는 반신반의하며 브라우저로 돌아가 다시 ‘새 글 추가’ 버튼을 눌렀다.

클릭.

이번에는 달랐다. 화면이 즉시 깜빡이며 네 번째 게시물, ‘새로운 글’이 목록 맨 아래에 나타났다. 솔라는 자기도 모르게 “와!” 하고 소리쳤다. 몇 번 더 누르자, 누를 때마다 새로운 게시물이 즉각적으로 화면에 추가되었다.

순간 머릿속에서 모든 조각이 맞춰졌다. 예전에 바닐라 자바스크립트로 DOM을 직접 조작하던 기억이 떠올랐다. createElement, appendChild… 화면에 무언가를 추가하기 위해 얼마나 많은 명령을 내려야 했던가. 하지만 지금은 그저 setPosts 함수를 호출하며 새로운 데이터 배열을 전달했을 뿐이다. 그러자 React가 알아서 화면의 어떤 부분이 바뀌어야 하는지 계산하고, 정확하게 그려준 것이다.

“알겠다! setPosts가 바로 React에게 ‘화면 다시 그려!’ 라고 외치는 신호탄이구나. 우리는 그저 ‘데이터가 이렇게 바뀌었어’라고 알려주기만 하면, 복잡한 화면 조작은 React가 전부 대신해주는 거네.”

데이터가 UI를 결정한다. 상태 기반 UI. 이 개념이 드디어 몸으로 느껴졌다. 게시물을 추가하고 삭제하는 모든 로직은 더 이상 DOM을 향하지 않았다. 오직 posts라는 상태(state) 데이터를 향할 뿐이었다. 상태가 바뀌면, UI는 그저 그 결과물을 보여주는 거울과도 같았다.

솔라는 신이 나서 삭제 기능까지 구현했다. 그리고는 만족스럽게 자신이 만든 결과물을 바라보았다. 새 글을 쓰고, 지우는 것이 자솔라재였다. 완벽한 미니 게시판이었다. 뿌듯함에 가득 차, 솔라는 이 멋진 결과물을 ‘저장’하는 의미로 브라우저의 새로고침 버튼을 눌렀다.

화면이 하얗게 번쩍인 후, 다시 나타난 게시판에는 그녀가 방금 추가했던 모든 글이 온데간데없이 사라져 있었다. 처음의 게시물 세 개만이 덩그러니 남아 있을 뿐.

“어…?”

그녀의 미소가 순식간에 굳었다. 데이터는 분명히 존재했지만, 그것은 오직 브라우저의 ‘메모리’ 안에서만 살아 숨 쉬는, 지극히 일시적인 존재였다. 페이지를 떠나는 순간, 모든 것이 리셋되었다.

“언니, 내가 쓴 글들… 다 어디 갔어?“

5장: 서버와의 첫 대화: Fetch와 useEffect로 데이터 읽기

솔라는 자신의 App.js 파일에서 한 부분을 블록으로 지정한 채, 멍하니 화면을 응시하고 있었다. 바로 어젯밤 그녀를 그토록 뿌듯하게 만들었던, 그리고 동시에 절망에 빠뜨렸던 코드였다.

const initialPosts = [
  { id: 1, title: '리액트 공부 시작', content: '컴포넌트가 신기하다.' },
  { id: 2, title: 'Props 사용법', content: '데이터를 내려줄 수 있다!' },
  { id: 3, title: '다음 단계는?', content: '이제 뭘 해야 할까?' },
];

// ...

const [posts, setPosts] = useState(initialPosts);

initialPosts 배열. 모든 문제의 근원이었다. 이 배열이 존재하는 한, 브라우저를 새로고침할 때마다 게시판의 상태는 어김없이 이 초기값으로 되돌아갔다. 솔라가 힘들게 추가했던 모든 새 게시물은 한낱 신기루처럼 사라졌다. 데이터는 오직 앱이 실행되는 동안의 ‘기억’ 속에만 존재할 뿐, 영속성을 갖지 못했다. 이것은 집이 없는 데이터였다.

“이게 문제야.” 솔라는 혼잣말을 하며 initialPosts 배열 전체를 주석 처리해버렸다. 그리고 useState의 인자를 빈 배열 []로 바꿨다. 코드가 존재하지 않는다면, 리셋될 데이터도 없으리라는 단순한 생각이었다. 파일을 저장하고 브라우저를 확인하자, 화면에는 텅 빈 게시판만이 그녀를 맞았다. 당연한 결과였다.

“언니, 내가 쓴 글들 다 어디 갔어?” 어젯밤의 허탈한 질문이 귓가에 맴돌았다. 답은 명확했다. 어디에도 가지 않았다. 애초에 제대로 ‘저장’된 적이 없었으니까.

“데이터가 머물 집이 없는 거구나.” 솔라가 나지막이 말했다. “데이터는 코드 안에 박제되어 있거나, 아니면 사용자가 입력하는 그 순간에만 잠깐 태어났다가 사라지는 유령이거나. 둘 중 하나야.”

솔라의 독백을 듣고 있던 루나가 조용히 다가왔다. 솔라의 텅 빈 화면과 주석 처리된 코드를 번갈아 보던 루나가 입을 열었다.

“그럼 데이터를 위한 영원한 집을 지어주면 어떨까? 이 앱 바깥 세상에. 앱은 그저 그 집에 들러서 최신 손님 명단을 확인하고 오는 거야. 새로고침할 때마다 말이야.”

“앱 바깥 세상의 집?”

“응. 바로 **서버(Server)**라는 집이야. 우리는 지금까지 앱이라는 한 건물 안에서 데이터의 생성, 관리, 소멸까지 모든 걸 해결하려고 했어. 이제 데이터는 그만의 독립된 공간, 서버에 살게 하고, 우리 앱은 필요할 때마다 그 집에 ‘요청’을 보내서 정보를 가져오는 거지.”

루나는 솔라의 컴퓨터에서 터미널 창을 하나 열었다. 그리고 몇 가지 명령어를 입력해 json-server라는 간단한 가짜 서버를 실행시켰다. 이어서 db.json이라는 파일을 만들고, 솔라가 방금 주석 처리했던 initialPosts 배열의 내용을 그대로 복사해 붙여넣었다.

// db.json
{
  "posts": [
    { "id": 1, "title": "리액트 공부 시작", "content": "컴포넌트가 신기하다." },
    { "id": 2, "title": "Props 사용법", "content": "데이터를 내려줄 수 있다!" },
    { "id": 3, "title": "다음 단계는?", "content": "이제 뭘 해야 할까?" }
  ]
}

“봐. 이제 데이터는 우리 앱 코드에서 완전히 분리됐어. db.json이라는 자기만의 파일을 가진, 독립된 존재가 된 거야. 그리고 저 터미널에서 실행 중인 서버가 이 집의 문지기 역할을 해줄 거야.”

루나는 브라우저 주소창에 http://localhost:3001/posts 라고 입력했다. 화면에는 db.json 파일의 내용이 날것 그대로 나타났다. 솔라는 눈을 동그랗게 떴다. 앱을 통하지 않고도 데이터에 직접 접근할 수 있다는 사실이 신기했다. 서버는 정말로 앱 바깥에 존재하는, 데이터를 위한 진짜 ‘공간’이었다.

“좋아, 집은 생겼어. 그럼 이제 우리 앱이 저 집에 어떻게 말을 걸지? ‘게시물 목록 좀 주세요’ 라고 요청해야 할 거 아냐.”

“그럴 때 쓰는 전화기가 바로 fetch야. ‘가져오다’라는 뜻이지.”

루나는 다시 App.js 파일로 돌아가 새로운 코드를 작성하기 시작했다. useState([])로 비어있는 상태는 그대로 두었다.

// App.js
import { useState, useEffect } from 'react'; // useEffect 추가!

function App() {
  const [posts, setPosts] = useState([]);

  // 언제 fetch를 실행할 것인가?
  useEffect(() => {
    fetch('http://localhost:3001/posts')
      .then(response => response.json())
      .then(data => {
        setPosts(data); // 서버에서 받아온 데이터로 state를 변경!
      });
  }, []); // 이 빈 배열이 중요해.

  // ... (이하 생략)
}

새로운 단어 useEffect가 등장했다. 솔라가 궁금하다는 듯 묻기도 전에 루나가 설명했다.

fetch는 ‘무엇을’ 할지에 대한 답이야. 서버에 데이터를 요청하는 일. 그런데 ‘언제’ 그 일을 할지도 중요해. useEffect는 바로 그 ‘언제’를 지정해주는 약속이야. [] 빈 배열과 함께 쓰면, ‘이 컴포넌트가 화면에 처음 딱 한 번 나타났을 때’ 이 안의 코드를 실행해줘, 라는 뜻이 돼.”

솔라는 코드의 흐름을 조용히 눈으로 좇았다.

  1. App 컴포넌트가 처음 화면에 그려진다. 이때 posts 상태는 빈 배열([])이므로, 화면에는 아무 게시물도 보이지 않는다.
  2. 바로 그 순간, useEffect가 약속대로 동작한다.
  3. fetch 함수가 http://localhost:3001/posts 주소로 전화를 걸어 데이터를 요청한다.
  4. 서버(문지기)는 db.json(집)에서 데이터를 꺼내 응답한다.
  5. 응답받은 데이터를 setPosts를 통해 posts 상태에 넣어준다.
  6. posts 상태가 바뀌었으므로, React는 화면을 다시 그린다. 이번에는 서버에서 받아온 데이터로 가득 찬 게시물 목록이 그려진다.

모든 조각이 머릿속에서 완벽하게 맞춰졌다. 솔라는 떨리는 손으로 파일을 저장하고 브라우저를 새로고침했다.

화면이 하얗게 깜빡였다. 찰나의 순간, 텅 빈 게시판이 보였다. 그리고 곧바로, 언제 그랬냐는 듯 세 개의 게시물이 화면에 나타났다. db.json 파일에 저장되어 있던 바로 그 데이터였다. 더 이상 코드 안에 하드코딩된 데이터가 아니었다. 앱 바깥, 서버라는 독립된 공간에서 ‘가져온’ 데이터였다.

“와…!”

솔라의 입에서 진심 어린 탄성이 터져 나왔다. 이것이 바로 브라우저와 서버의 첫 ‘대화’였다. 앱이 시작될 때마다 서버에 안부를 묻고, 최신 데이터를 받아와 화면에 보여주는 구조. 게시판 진화의 거대한 전환점, ‘원격 데이터 연결’이 이루어지는 순간이었다. 새로고침해도 데이터가 사라지지 않는다는 것, 그 단순한 사실이 주는 안정감은 상상 이상이었다.

솔라는 신이 나서 기존의 ‘새 글 추가’ 버튼을 눌러보았다. 네 번째 게시물이 화면에 잘 추가되었다. 하지만 그녀는 이제 이것이 완전한 해결책이 아님을 알았다.

“언니, 알겠어! 새로고침해도 글이 보이는 건, 앱이 시작할 때마다 fetch로 서버의 데이터를 다시 읽어오기 때문이야. 그런데 내가 방금 추가한 네 번째 글은 새로고침하면 사라지겠지. 왜냐면 setPosts는 내 브라우저의 ‘기억’만 바꾼 거지, 저기 저 db.json이라는 ‘집’에 찾아가서 손님 명단을 바꿔놓은 건 아니니까.”

솔라는 스스로 문제의 핵심을 꿰뚫어 보고 있었다. 데이터를 읽어오는 ‘첫 대화’에는 성공했지만, 아직 데이터를 수정하거나 삭제하는, 더 깊은 대화를 나누는 방법은 알지 못했다.

“맞아. 그럼 이제 서버에 어떻게 말을 걸어야, 손님 명단을 바꿔달라고 부탁할 수 있을까?“

6장: 게시물 생성 및 삭제: 서버 데이터 조작의 시작

솔라의 키보드 위에서 손가락이 잠시 멈칫했다. 화면에는 어제 완성한 게시판의 handleAddPost 함수가 열려 있었다. 새 글을 추가하는 버튼을 눌렀을 때 실행되는, 이제는 익숙한 코드였다. 하지만 솔라의 표정은 밝지 않았다. 그녀는 이 함수가 절반의 성공이자 절반의 실패라는 사실을 이미 알고 있었다.

const handleAddPost = () => {
  const newPost = { id: Date.now(), title: '새로운 글', content: '추가 성공!' };
  setPosts([...posts, newPost]);
};

setPosts. 이 함수는 솔라의 브라우저, 즉 이 웹 애플리케이션의 ‘기억’에만 새 게시물을 추가할 뿐이었다. 데이터의 진짜 집인 서버의 db.json 파일에는 아무런 소식도 전하지 않는다. 새로고침 한 번이면 모든 게 사라질, 덧없는 존재였다. 그녀는 어젯밤 루나와의 대화를 떠올렸다. ‘서버에 어떻게 말을 걸어야, 손님 명단을 바꿔달라고 부탁할 수 있을까?’

“말을 건다…”

솔라는 useEffect 블록 안에 있던 fetch 코드를 뚫어지라 쳐다봤다. 그것이 서버와 대화하는 유일한 방법이었다. 그녀는 한 가지 가설을 세웠다. 어쩌면 새 글을 추가한 직후에 다시 서버에 말을 걸어 최신 목록을 받아오면 되지 않을까? 그녀는 handleAddPost 함수를 수정하기 시작했다.

const handleAddPost = () => {
  const newPost = { id: Date.now(), title: '새로운 글', content: '추가 성공!' };
  setPosts([...posts, newPost]); // 1. 일단 내 브라우저에 추가하고

  // 2. 서버에 다시 목록을 요청하면... 반영되지 않을까?
  fetch('http://localhost:3001/posts')
    .then(response => response.json())
    .then(data => setPosts(data));
};

논리적으로는 그럴듯했다. 하지만 브라우저를 새로고침하고 테스트해 본 결과는 처참했다. 새 글을 추가하는 버튼을 누르자, 네 번째 게시물이 잠깐 나타났다가 눈 깜짝할 사이에 사라지고 다시 원래의 세 개로 돌아왔다. 서버의 목록에는 당연히 네 번째 게시물이 없었으니, fetch는 언제나 세 개의 게시물만 가져왔던 것이다.

“아… 안되네.”

솔라는 허탈하게 중얼거렸다. “서버랑 대화는 하는데, 항상 ‘손님 명단 좀 보여주세요’라는 말밖에 못 하는 기분이야. ‘이 사람 명단에 추가해주세요’라고 말하는 법을 모르겠어. 읽기만 가능하고 쓰기는 안 되는 건가?”

서버와의 통신은 데이터를 ‘읽기’만 가능하다는 막연한 생각이 그녀를 가로막고 있었다.

그때, 솔라의 혼잣말을 들은 루나가 다가와 모니터를 보았다. 솔라의 좌절감이 담긴 코드를 잠시 살펴보던 루나가 입을 열었다.

“솔라, 네가 fetch로 보내는 요청은 내용이 없는 엽서랑 같아. 그냥 주소(http://localhost:3001/posts)만 덜렁 적어서 보내는 거지. 우체국에서는 주소만 적힌 엽서를 받으면, 당연히 그 주소에 사는 사람 명단을 알려주는 걸 기본 업무로 생각해.”

“기본 업무?”

“응. 아무런 추가 정보 없이 그냥 ‘요청’하면, 그건 ‘데이터를 달라(GET)‘는 뜻으로 약속되어 있어. 만약 ‘명단에 추가(CREATE)‘해달라거나 ‘명단에서 빼달라(DELETE)‘는 다른 업무를 부탁하려면, 그냥 엽서를 보내면 안 돼. 요청서 양식에 맞춰서 내용을 채워 보내야지.”

루나는 handleAddPost 함수 안의 fetch 부분을 가리켰다. “여기에 그 ‘요청서 양식’을 추가할 수 있어. fetch 함수의 두 번째 인자로 말이야.”

루나의 안내에 따라 솔라는 코드를 수정했다. 마치 새로운 언어의 문법을 배우는 기분이었다.

const handleAddPost = () => {
  const newPost = { title: '서버에 저장된 글', content: '성공!' }; // id는 서버가 만들어줄 테니 뺌.

  fetch('http://localhost:3001/posts', {
    method: 'POST', // 요청의 목적: '생성(POST)'
    headers: {
      'Content-Type': 'application/json', // 보내는 내용물의 종류: 'JSON 형식'
    },
    body: JSON.stringify(newPost), // 내용물: '새 게시물 정보'
  })
  .then(response => response.json())
  .then(savedPost => {
    // 서버가 저장 후 돌려준, id가 포함된 게시물 정보로 state 업데이트
    setPosts([...posts, savedPost]);
  });
};

method: 'POST'. 솔라의 눈에 그 한 줄이 선명하게 들어왔다. 이것이 바로 ‘명단에 추가해주세요’라는, 명시적인 의사 표현이었다. headers는 보내는 데이터의 형식을 알려주는 명찰 같았고, body는 전달할 데이터 그 자체였다. 더 이상 주소만 덜렁 적힌 엽서가 아니었다. 목적과 내용이 분명한 하나의 정식 ‘요청서’였다.

솔라는 심호흡 한번 하고, 브라우저에서 ‘새 글 추가’ 버튼을 눌렀다. ‘서버에 저장된 글’이라는 제목의 네 번째 게시물이 화면에 나타났다. 여기까지는 이전과 같았다. 진짜 시험은 지금부터였다.

솔라는 마우스 커서를 브라우저의 새로고침 버튼 위로 가져갔다. 잠시 망설인 후, 클릭했다.

화면이 하얗게 번쩍였다. 솔라는 자기도 모르게 눈을 질끈 감았다 떴다. 그리고 그녀의 눈앞에 나타난 것은… 네 개의 게시물이었다. 방금 추가한 ‘서버에 저장된 글’이 사라지지 않고 꿋꿋하게 자리를 지키고 있었다. 옆에 열어둔 터미널의 db.json 파일에도 네 번째 게시물이 기록되어 있었다.

“됐다! 진짜 저장이 됐어!”

솔라의 목소리가 흥분으로 떨렸다. fetch는 단순히 데이터를 가져오는(GET) 도구가 아니었다. 서버에 새로운 데이터를 생성(POST)해달라고 요청할 수도 있는, 훨씬 더 강력한 ‘대화’ 수단이었던 것이다. 브라우저의 기억(state)을 바꾸는 것과 서버의 기록(db.json)을 바꾸는 두 가지 행위가 드디어 하나로 연결되었다.

자신감을 얻은 솔라는 곧바로 삭제 기능에 도전했다. 각 게시물 컴포넌트에 삭제 버튼을 추가하고, handleDelete 함수를 만들어 App.js에 작성했다.

const handleDeletePost = (id) => {
  fetch(`http://localhost:3001/posts/${id}`, {
    method: 'DELETE', // 요청의 목적: '삭제(DELETE)'
  })
  .then(() => {
    // 서버에서 삭제 성공 후, 브라우저의 state에서도 해당 게시물을 제거
    setPosts(posts.filter(post => post.id !== id));
  });
};

이번에는 methodDELETE였다. 마치 ‘이 ID를 가진 손님을 명단에서 빼주세요’라고 말하는 것 같았다. 테스트 결과는 놀라웠다. 삭제 버튼을 누르자 게시물이 화면에서 사라졌고, 새로고침해도 다시 나타나지 않았다. 브라우저와 서버가 완벽하게 동기화된 것이다.

게시판 진화의 또 다른 거대한 발걸음, 서버 데이터 조작이 가능해진 순간이었다. 솔라는 이제 게시물을 새로 작성하고 서버에 영구히 기록하거나, 불필요한 게시물을 서버에서 완전히 제거할 수 있게 되었다.

솔라는 자신이 만든 게시판을 뿌듯하게 바라보며 새 글을 쓰고 지우는 것을 반복했다. 그러다 방금 막 작성한 게시물의 제목에 오타가 있는 것을 발견했다.

“아, ‘리액트’가 아니라 ‘리엑트’라고 썼네.”

그녀는 습관적으로 제목을 수정할 방법을 찾았다. 하지만 수정 버튼은 어디에도 없었다.

“음… 이걸 지우고 다시 써야 하나? 근데 겨우 글자 하나 고치자고 전체를 지우고 새로 쓰는 건 너무 비효율적인데…”

솔라는 잠시 생각에 잠겼다. ‘가져오기(GET)’, ‘생성하기(POST)’, ‘삭제하기(DELETE)’. 서버와 대화할 때 쓸 수 있는 몇 가지 동사를 이제 알게 되었다. 그렇다면 ‘고치기(UPDATE)‘를 위한 동사도 따로 있지 않을까? 그녀는 아직 나누지 못한, 더 섬세한 대화의 필요성을 느끼고 있었다.

7장: 게시물 수정: 완전한 브라우저-서버 대화 구조

솔라의 코드 편집기에는 두 개의 fetch 함수가 나란히 놓여 있었다. 하나는 method: 'DELETE', 다른 하나는 method: 'POST'. 방금 발견한 오타, ‘리엑트’를 ‘리액트’로 바로잡기 위해 그녀가 생각해 낸 최선의 방법이었다. 먼저 오타가 있는 게시물을 ID로 찾아 서버에서 삭제하고, 곧이어 같은 내용에 제목만 수정한 새 게시물을 서버에 생성하는 방식이었다.

그녀는 두 함수를 순서대로 호출하는 임시 함수 handleUpdatePost를 만들어 실행했다. 브라우저 화면에서 오타가 있던 게시물이 사라졌다가, 거의 동시에 수정된 새 게시물이 목록 맨 끝에 나타났다. 새로고침해도 그 상태는 유지되었다. 기술적으로, 그녀는 게시물 ‘수정’에 성공했다. 하지만 솔라는 전혀 기쁘지 않았다. 단지 글자 하나를 고치기 위해, 서버에 데이터를 지웠다가 다시 쓰는 두 번의 요청을 보내야 한다는 사실이 어딘가 부자연스럽고 낭비처럼 느껴졌다. 이것은 ‘수정’이라기보다는 ‘파괴 후 재건’에 가까웠다.

“언니, 이거 봐. ‘리엑트’를 고쳤어.”

솔라가 마지못해 말하자, 옆에서 지켜보던 루나가 화면을 들여다보았다. 솔라의 코드에 담긴 의도를 파악한 루나는 잠시 생각에 잠기더니, 입을 열었다.

“네가 쓴 책의 한 페이지에 오타가 났다고 생각해 봐. 그래서 출판사에 연락했어. ‘52페이지 셋째 줄의 ‘리엑트’를 ‘리액트’로 바꿔주세요’라고 말하는 대신, ‘52페이지 전체를 찢어버리고, 제가 지금부터 불러주는 내용으로 새 페이지를 붙여주세요’라고 말하는 것과 같네.”

루나의 비유에 솔라는 얼굴을 살짝 붉혔다. 정확했다. 자신이 한 일이 바로 그것이었다. “음… 그렇게 말하니까 확실히 이상하네. 그냥 틀린 글자만 딱 고쳐달라고 말할 수는 없는 걸까?”

솔라의 질문은 더 이상 막연한 궁금증이 아니었다. ‘생성(POST)‘과 ‘삭제(DELETE)‘라는 투박한 대화 방식 너머에, 더 섬세하고 효율적인 소통 방법이 있을 것이라는 확신에 찬 질문이었다. 데이터를 업데이트하기 위해 기존 데이터를 통째로 지우고 새로 쓰는 방식이 유일한 해답일 리 없었다.

“당연히 있지.” 루나가 미소 지으며 말했다. “출판사에 ‘수정 요청서’를 보내는 거야. 그 요청서에는 두 가지 종류가 있어. 하나는 PUT, 다른 하나는 PATCH야.”

루나는 솔라의 코드 편집기에서 handleUpdatePost 함수를 수정하기 시작했다. DELETEPOST를 연달아 호출하던 복잡한 로직은 지워졌다. 그 자리에는 새로운 fetch 함수 하나만이 간결하게 들어섰다.

const handleUpdatePost = (id, newTitle) => {
  fetch(`http://localhost:3001/posts/${id}`, {
    method: 'PATCH', // 요청의 목적: '부분 수정(PATCH)'
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ title: newTitle }), // 내용물: '바뀌는 부분만'
  })
  .then(response => response.json())
  .then(updatedPost => {
    // 서버가 수정 후 돌려준 정보로 state 업데이트
    setPosts(posts.map(post => 
      post.id === id ? updatedPost : post
    ));
  });
};

method: 'PATCH'. 솔라의 눈에 그 단어가 박혔다. 생성도, 삭제도 아닌 새로운 동사. ‘부분적으로 수정한다’는 의미의 동사였다. 더 놀라운 것은 body에 들어가는 내용이었다. 게시물 객체 전체가 아니라, { title: newTitle } 처럼 바뀌는 부분의 정보만 콕 집어 전달하고 있었다.

“이게 바로 ‘수정 요청서’야. PATCH는 ‘이 ID를 가진 게시물의 내용 중, 내가 지금 보내주는 이 부분만 바꿔줘’라는 뜻이야. 게시물의 다른 내용, 예를 들어 내용(content)이나 작성일 같은 건 전혀 건드리지 않고 오직 제목(title)만 안전하게 수정하지.”

루나는 덧붙였다. “PUT이라는 요청서도 있는데, 그건 ‘이 ID를 가진 게시물의 전체 내용을 내가 보내는 걸로 통째로 바꿔줘’라는 뜻이야. POST와 비슷해 보이지만, PUT은 특정 ID의 대상을 완전히 교체할 때 사용한다는 점에서 달라.”

솔라는 고개를 끄덕였다. 비효율적인 ‘파괴 후 재건’이 아니라, 외과수술처럼 정교한 ‘부분 교체’가 가능하다는 사실에 눈이 번쩍 뜨였다. 그녀는 각 게시물에 ‘수정’ 버튼을 만들고, 버튼을 누르면 제목을 수정할 수 있는 입력 창이 나타나도록 UI를 변경했다. 그리고 수정한 내용을 handleUpdatePost 함수로 전달했다.

떨리는 마음으로 브라우저에서 ‘리엑트’ 게시물의 ‘수정’ 버튼을 눌렀다. 제목 부분이 입력 창으로 바뀌었고, 그녀는 오타를 ‘리액트’로 바로잡은 뒤 엔터 키를 쳤다.

화면의 제목이 즉시 ‘리액트’로 바뀌었다. 그리고 새로고침 버튼을 눌렀다. 수정된 제목은 사라지지 않았다. db.json 파일을 확인하자, 해당 게시물의 content는 그대로인 채 title 값만 정확히 바뀌어 있었다.

“해냈다…!”

브라우저와 서버가 나누는 대화의 마지막 조각이 맞춰지는 순간이었다. 데이터를 가져오고(GET), 새로 만들고(POST), 지우고(DELETE), 그리고 마침내 수정까지(PATCH/PUT) 할 수 있게 된 것이다. 이 네 가지 ‘대화법’을 통해, 브라우저의 화면(state)과 서버의 저장소(DB)는 완벽하게 하나처럼 움직였다. 정적이었던 HTML 문서는 마침내 서버와 자유롭게 대화하는 완전한 애플리케이션으로 진화했다.

솔라는 자신이 걸어온 길을 되돌아보았다. 아무런 기능도 없던 HTML 뼈대에서 시작해, DOM 조작으로 생명을 불어넣고, React 컴포넌트로 복잡성을 다스렸으며, state로 데이터 흐름을 만들었다. 그리고 fetch를 통해 서버와 대화를 시작했고, 마침내 서버의 데이터를 자솔라재로 조작하는 단계에 이르렀다. 파편처럼 흩어져 있던 기술들이 ‘게시판의 진화’라는 하나의 이야기로 꿰어지는 느낌이었다.

그때, 루나가 새로운 질문을 던졌다. “좋아, 솔라. 이제 이 게시판에 ‘좋아요’ 기능을 추가하고 싶어. 어떻게 만들 거야?”

예전 같았으면 막막했을 질문. 하지만 지금의 솔라는 달랐다. 그녀는 더 이상 하나의 기술에 매몰되지 않았다. 전체 아키텍처의 흐름 속에서 각 기술의 역할을 설계할 수 있었다.

“일단…” 솔라는 키보드에 손을 올리며 자신 있게 말했다.

PostItem 컴포넌트에 ‘좋아요’ 버튼이랑 카운트를 보여줄 span 태그를 추가할 거야. 이게 UI 뼈대지. 그리고 db.json의 게시물 데이터마다 likes 필드를 추가해서 초기값을 0으로 설정해야 해. 서버에 데이터 구조가 바뀌는 거니까.”

그녀는 잠시 숨을 고르고 말을 이었다.

“앱이 로딩될 때 useEffect 안의 fetchlikes 데이터까지 같이 불러올 거고, 그 데이터는 posts state에 담겨서 각 PostItem에 props로 전달될 거야. 사용자가 ‘좋아요’ 버튼을 누르면 handleLike 같은 함수가 실행되겠지. 그 함수는 fetch를 이용해서, methodPATCH로, 주소는 /posts/${id}로 요청을 보낼 거야. body에는 { likes: currentLikes + 1 } 이렇게 바뀔 내용만 담아서. 이건 ‘부분 업데이트 요청’이니까. 서버에서 성공적으로 업데이트됐다는 응답이 오면, 그 응답에 담긴 새 게시물 정보로 setPosts를 호출해서 화면도 새로 그리고. 그러면 끝!”

말을 마친 솔라의 얼굴에는 확신이 가득했다. 그녀는 더 이상 루나의 안내를 기다리지 않았다. 스스로 문제의 아키텍처를 설계하고, 필요한 기술들을 제자리에 배치하고 있었다.

루나는 아무 말 없이 미소 지으며 고개를 끄덕였다. ‘게시판은 정적 화면에서 서버와 대화하는 앱으로 진화했다.’ 처음엔 막연하기만 했던 그 문장이, 이제 솔라에게는 직접 쌓아 올린 견고한 건축물처럼 선명하게 보였다. 솔라는 새로운 기능을 만들기 위해, 망설임 없이 PostItem.js 파일을 열었다. 진화는 끝이 아니었다. 새로운 시작이었다.