리액트를 배울 때 가장 많이 듣는 말이 '선언형(declarative)'이다. 처음엔 이 단어가 뜬구름처럼 들린다. 하지만 자바스크립트로 화면을 직접 갱신해 본 사람이라면, 명령형(imperative)과 나란히 놓고 보는 순간 바로 이해된다. 명령형은 어떻게 바꿀지를 단계마다 지시하는 방식이고, 선언형은 무엇을 보여줄지 결과만 적는 방식이다. 좋아요 버튼 하나를 양쪽 방식으로 만들어 보면 차이가 선명하게 드러난다.


명령형은 '어떻게'를 전부 적는다. 순수 자바스크립트로 좋아요 버튼을 토글하려면, 지금 상태를 확인하고 글자를 바꾸고 클래스를 켜고 끄는 동작을 내가 일일이 명령해야 한다.


const btn = document.querySelector(".like");
let liked = false;
btn.addEventListener("click", () => {
liked = !liked;
btn.textContent = liked ? "좋아요 취소" : "좋아요";
btn.classList.toggle("active", liked);
});


동작은 한다. 버튼을 누르면 글자가 '좋아요'와 '좋아요 취소' 사이를 오가고, active 클래스도 붙었다 떨어진다. 문제는 상태가 바뀔 때마다 손봐야 할 곳이 늘어난다는 것이다. 여기에 색깔을 더 바꾸고, 옆에 숫자를 표시하고 싶으면 click 안에 갱신 코드를 계속 밀어 넣어야 한다. 상태와 화면을 잇는 실을 내가 한 가닥씩 손으로 쥐고 있는 셈이라, 실이 늘수록 엉킨다.


선언형은 '무엇을'만 적는다. 같은 버튼을 리액트로 만들면 접근이 뒤집힌다. 나는 liked가 참일 때와 거짓일 때 화면이 각각 어떻게 생겼는지만 적는다. 상태를 바꾸는 순간 화면을 어떻게 갱신할지는 리액트가 알아서 한다.


import { useState } from "react";

function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button
className={liked ? "active" : ""}
onClick={() => setLiked(!liked)}
>
{liked ? "좋아요 취소" : "좋아요"}
</button>
);
}


결과를 따라가 보자. 처음엔 liked가 거짓이라 버튼에 '좋아요'가 적히고 클래스는 비어 있다. 버튼을 누르면 setLiked(!liked)가 상태를 참으로 뒤집고, 리액트가 이 컴포넌트를 다시 실행한다. 이번엔 liked가 참이니 글자는 '좋아요 취소', 클래스는 active가 된다. 다시 누르면 원래대로 돌아간다. 명령형 쪽에 있던 textContentclassList.toggle 같은 갱신 코드가 여기엔 한 줄도 없다. 나는 각 상태의 모습만 선언했고, 화면을 그 모습으로 맞추는 일은 리액트가 대신했다.


상태가 진실의 원천이다. 선언형이 편한 진짜 이유가 이것이다. 명령형에선 화면에 흩어진 글자와 클래스가 제각각 상태를 조금씩 들고 있어서, 어느 하나가 어긋나면 화면이 실제 상태와 따로 논다. 선언형에선 liked라는 값 하나가 진실의 원천(single source of truth)이고, 화면은 그 값을 비추는 거울일 뿐이다. 화면이 이상하면 상태만 들여다보면 된다. 화면 여기저기를 뒤지며 어느 갱신을 빼먹었나 찾던 디버깅이, 값 하나 확인하는 일로 줄어든다.


리액트가 바뀐 부분만 찾아 바꾼다. 상태가 바뀔 때마다 컴포넌트 함수 전체가 다시 실행된다고 하면, 화면 전체를 매번 새로 그려 느리지 않냐는 의심이 든다. 안 그렇다. 리액트는 새로 그린 결과와 이전 결과를 속으로 비교해서, 실제로 달라진 부분만 골라 진짜 DOM에 반영한다. 이 과정을 재조정(reconciliation)이라 부른다. 위 버튼 예에서 상태가 바뀌어도 리액트가 실제로 손대는 건 버튼의 글자와 클래스뿐이고, 나머지는 건드리지 않는다. 그래서 결과 모양만 선언해도 성능 손해 없이 굴러간다.


화면이 상태와 어긋날 일이 사라진다. 명령형으로 화면을 짜다 보면 가장 자주, 가장 오래 잡는 버그가 상태와 화면의 불일치다. 어떤 경로에선 글자를 바꾸는 걸 깜빡하고, 어떤 경로에선 클래스 끄는 걸 빼먹어서, 데이터상으론 좋아요가 꺼졌는데 화면엔 여전히 켜진 채로 남는 식이다. 갱신 코드가 화면 곳곳에 흩어져 있으니 하나만 빠져도 티가 안 나고, 재현도 잘 안 돼 리포트만 쌓인다. 선언형에선 이런 버그가 구조적으로 생기기 어렵다. 화면은 상태 하나로부터 매번 통째로 다시 계산되니, 어느 경로로 왔든 같은 상태면 같은 화면이 나온다. 갱신을 빼먹을 코드 자체가 없는 셈이다. 예전에 필터 조건이 바뀔 때 목록만 갱신하고 상단 개수 표시를 안 고쳐서, 스무 개짜리 목록 위에 '3건'이라고 적혀 있던 창피한 버그가 있었다. 선언형이었다면 개수도 같은 상태를 보고 그렸을 테니 애초에 어긋날 수가 없었다.


조건부 렌더링도 선언형이다. 상황에 따라 다른 걸 보여주는 것도, 화면을 숨겼다 켰다 명령하는 대신 '이 조건이면 이 모양'이라고 적는다. 로그인 여부에 따라 다른 문구를 띄우는 예다.


function Header({ isLoggedIn }) {
return (
<div>
{isLoggedIn ? <p>환영합니다</p> : <p>로그인하세요</p>}
</div>
);
}


isLoggedIn이 참이면 화면에 '환영합니다'가, 거짓이면 '로그인하세요'가 나타난다. 상태가 바뀌면 문구도 따라 바뀐다. 어느 문단을 지우고 어느 문단을 새로 만들지 지시하는 대신, 조건마다 무엇이 보일지만 적은 것이다.


리스트도 선언형으로 그린다. 여러 개를 반복해 그릴 때도 appendChild를 도는 대신, 배열을 화면 모양으로 변환한다. 자바스크립트의 map이 그대로 쓰인다.


function TodoList({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}


items 배열이 세 칸이면 화면에 목록 항목이 세 개 그려진다. 배열에 항목을 더하고 상태를 바꾸면 목록도 저절로 늘어난다. 각 항목에 붙인 key는 리액트가 어느 항목이 그대로고 어느 게 새로 생겼는지 구별하려고 요구하는 표식이니, 항목마다 겹치지 않는 값을 넣어줘야 한다. 이걸 빼먹으면 경고가 뜨고, 목록을 정렬하거나 지울 때 엉뚱한 항목이 남는 골치 아픈 버그를 만난다.


결국 값을 바꾸면 화면이 따라온다. 선언형 렌더링은 화면을 직접 주무르는 대신 상태라는 값 하나를 진실로 삼고, 그 값에 대응하는 화면 모양만 적는 방식이다. 값을 바꾸면 화면은 리액트가 맞춰준다. 명령형에 익숙한 손은 자꾸 화면을 직접 건드리려 들지만, '화면을 바꾸지 말고 상태를 바꿔라'라는 한마디를 되뇌면 금세 방향이 잡힌다. 이 사고방식이 몸에 배는 순간, 리액트 코드가 왜 이렇게 생겼는지 대부분 납득이 간다.