첫 프로토타입의 성공이 안겨준 흥분은 오래가지 않았다. 그것은 가능성을 증명했을 뿐, 아직 넘어야 할 산이 겹겹이 쌓여 있었기 때문이다. 팀원들은 앤드류가 만든 Note.server.js
파일을 둘러싸고 곧바로 다음 단계에 대한 논의를 시작했다.
“좋아, 파일 읽기는 성공했어.” 조쉬 스토리가 말했다. “그럼 이제 여기에 버튼을 추가해서, 클릭하면 노트를 수정하는 입력창을 보여주는 기능을 넣어볼까?”
지극히 자연스러운 발상이었다. 정적인 콘텐츠를 보여줬으니, 이제 상호작용을 더할 차례였다. 그는 자연스럽게 키보드에 손을 올리고, 익숙한 코드를 타이핑하기 시작했다.
// Note.server.js
import fs from 'fs';
import { useState } from 'react';
function Note() {
const [isEditing, setIsEditing] = useState(false);
const content = fs.readFileSync('note.txt', 'utf8');
if (isEditing) {
// ... 수정용 입력창 UI
}
return (
<div>
<p>{content}</p>
<button onClick={() => setIsEditing(true)}>수정</button>
</div>
);
}
코드는 문법적으로는 완벽했다. 하지만 그 코드가 놓일 ‘장소’가 잘못되었다. 그가 코드를 실행하자, 시스템은 즉시 에러를 뿜어냈다.
'useState' is not supported in Server Components.
에러 메시지는 명확했다. 서버 컴포넌트에서는 useState
를 사용할 수 없다. onClick
같은 이벤트 핸들러 역시 마찬가지였다.
“잠깐, 왜 안 되는 거지?” 한 주니어 엔지니어가 혼란스러운 표정으로 물었다.
그 질문에 로렌 탄이 차분하게 설명하기 시작했다.
“서버 컴포넌트에는 ‘상태’라는 개념이 존재할 수 없습니다. 상태는 사용자의 상호작용에 따라 ‘변하는 값’을 의미하니까요. 서버 컴포넌트는 요청이 들어오는 그 순간, 단 한 번 실행되고 렌더링 결과를 만든 뒤 사라집니다. 그 이후의 변화를 기억하거나 관리할 주체가 없어요.”
그녀의 설명은 핵심을 꿰뚫었다. useState
와 useEffect
, 그리고 onClick
같은 모든 인터랙션 관련 기능들은 사용자의 브라우저, 즉 클라이언트 환경에 ‘살아있는’ 컴포넌트를 전제로 만들어진 도구들이었다. 서버는 그저 일회성 렌더링 작업을 수행하는 공장일 뿐, 사용자의 클릭 하나하나에 반응하는 상점이 아니었다.
순간 팀원들 사이에 침묵이 흘렀다. 어떤 이의 얼굴에는 실망감이, 어떤 이의 얼굴에는 깊은 깨달음이 스쳐 지나갔다.
이것은 버그나 한계가 아니었다. 이것은 서버 컴포넌트의 본질 그 자체였다.
“오히려 좋아.”
세바스찬이 침묵을 깨고 말했다. 모두의 시선이 그에게 향했다.
“이 제약이야말로 서버 컴포넌트의 가치를 증명하는 거야. 우리는 이 제약을 통해 개발자가 아무 고민 없이 서버 컴포넌트에 인터랙션 코드를 섞어 넣는 실수를 원천적으로 막을 수 있어. 상태와 상호작용이 필요하다? 그럼 클라이언트 컴포넌트를 써야지. 명확하잖아.”
그의 말에 팀원들의 표정이 밝아졌다. 그렇다. 이것은 제약이 아니라, 명확한 ‘가이드라인’이었다. 개발자는 더 이상 ‘이 로직을 어디에 둬야 할까?’를 고민할 필요가 없었다. React가 스스로 규칙을 제시하고, 그 규칙을 따르도록 강제하는 것이다.
- 데이터를 가져와서 보여주기만 한다? 서버 컴포넌트.
- 사용자의 클릭, 입력 등 상호작용이 필요하다? 클라이언트 컴포넌트.
이 단순하고 강력한 규칙. 이 제약을 통해 서버 컴포넌트는 오직 데이터 페칭과 정적 렌더링이라는 자신의 역할에만 충실할 수 있게 되었다. 그리고 이 명확한 역할 분담이야말로, 과거의 복잡성을 해결할 가장 중요한 열쇠였다.