Back to list

#01. 왜 "React"인가? - React의 특징

목차14

TL;DR

  • React는 3가지 기본 철학을 가짐:
    • UI = 상태의 함수 (선언형 UI)
    • 컴포넌트 = 함수
    • 단방향 데이터 흐름 (State Hoisting)
  • 동일한 선언형 UI 방식인 Compose와 많이 닮았으나, 다른 부분도 존재:
    • 재구성(Rerender || Recomposition) 범위
    • Rules of Hook (Slot & State 소비 방식)
    • Rerender 트리거 방식에 따른 Array/Object 취급 방식

개요

제일 먼저 React를 공부하기에 앞서, 이 라이브러리가 무엇을 위한 라이브러리이고 어떤 철학을 가지고 있는지 알아보고자 했다.
빠르고 직관적인 이해를 위해, 내가 알고있는 다른 프레임워크에도 등장하는 철학이나 개념은 알고 있으면 좋을테니까.

처음 공부를 시작하기 전에 Claude에게 물어봤을 때 “Compose는 React의 직계 후손이다” 라고 대답했었다.
React로부터 Compose가 만들어졌다기보다는, React의 철학(선언형 UI)를 가장 잘 계승한 라이브러리 중 하나가 Compose라고 이해하는것이 좋을 것 같다.


사용 이유

강의에서는 React의 사용 이유를 다음과 같이 정리하였다:

  • Single Page Application (새로고침 없이, 데이터(state)만 교체. 현대적 안드로이드 앱 구조 SAA와 유사)
  • Html 재사용 용이
  • React Native: 조금만 확장하여 앱 개발 가능

그 외로도 한국에서는 React를 많이 쓰고, 그만큼 강의나 커뮤니티가 활성화되어있어 쉽게 자료를 구할 수 있다는 측면도 중요한 것 같다.
근데 이 블로그는 React와는 전혀 관련없이 순수 Astro 프레임워크로만 구성되어있다.


철학

Q. 일단 시작하기에 앞서, 왜 React를 쓰고 핵심 철학이 무엇인지, 내가 주로 다뤄왔던 스택들을 바탕으로 설명해줄래?

본격적으로 강의를 듣기 전 Claude에게 위와 같이 물어보았고,
내가 얻은 답은 “기존 바닐라 DOM의 문제를 해결한 React의 방식” + 세가지 철학 이었다.

0. 기존 바닐라 DOM의 문제

내가 옛날의 웹 프론트엔드 코드를 볼 기회는 거의 없었지만, 바닐라 DOM은** “언제 무엇을 해라”라는 명령형 구조** 에 가까웠다.

// "무엇을 해라" (명령형)
const el = document.querySelector('#overlay')!;
el.classList.add('open');
el.querySelector('img')!.src = nextSrc;
counter.textContent = `${i + 1} / ${total}`;
if (i === 0) prevBtn.setAttribute('disabled', '');
else prevBtn.removeAttribute('disabled');
  • “How”에 집중된 구조

    • “어떻게 UI를 바꿀 것인가”에 대한 절차적 단계가 명시되어있음.
    • Ex) if ~ else ~: 조건에 따라 특정 UI를 그리거나 숨긴다.
  • SSOT(Single Source of Truth)의 부재

    • UI 상태가 JS 변수에도 존재하고(i, total) DOM 객체(counter.textContent)에도 존재한다.
    • 까딱 실수하면 JS 변수와 DOM 객체의 데이터의 동기화가 깨지기 쉬운 구조
  • DOM 직접 접근 방식

    • 어떤 프로그래밍 언어든 “결합도의 상승”은 항상 경계해야한다 — 유지보수 비용과 연결되므로.
    • 만약 img와 같은 태그를 나중에 div로 바꾸는 순간, el.querySelector('img')는 고장나버리고 화면이 깨지게된다.

요약하자면, 프로젝트 규모가 커져갈수록 화면 상태와 데이터의 괴리가 발생하기 쉬워지고, 유지보수가 힘들어지는 구조적인 문제로 이어졌다.

그렇다면 React는 이를 어떻게 해결하였을까?

화면을 고치지 마라. 상태(state)를 고치고, 화면은 다시 그려라(re-render).

1. UI = 상태의 함수

React를 비롯한 선언형 UI는 아래와 같은 수식으로 명쾌하게 정의된다:

UI = f(props, state)

함수는 어떤 input이 있으면 그에 해당하는 output을 반환하는 구조이다.
선언형 UI는 UI 자체를 특정한 값이 주어질 때, UI 로직에 의해 상응하는 “UI”를 반환하는 함수로 바라본다.

아래의 코드에서, Lightbox라는 UI는 images라는 바깥에서 온 Input(props)을, 내부 State인 i에 따라 UI를 그리는 방법을 선언할 뿐이다.

// "무엇이다" (선언형)
function Lightbox({ images }) {
  const [i, setI] = useState(0);
  return (
    <div className="overlay open">
      <img src={images[i]} />
      <span>{i + 1} / {images.length}</span>
      <button disabled={i === 0} onClick={() => setI(i - 1)}>이전</button>
    </div>
  );
}

위 코드의 예시에서는 앞서 언급되었던 문제들이 명료하게 해결되어있다:

  • 데이터의 일원화 (SSOT)

    • return문이 반환하는 Html 세그먼트는, i라는 하나의 값에 따라 변경된다.
  • DOM 결합도 문제 해결

    • 그 어디에 Selector 문법이 존재하지 않는다.
    • 이전에는 “찾아서 조작하는” 방식이었다면, React에서는 “데이터를 직접 주입”한다.

2. Component = Function

위 예시에서처럼, 그냥 JS 함수가 UI를 return하는 구조이다.
이는 Compose에서 Composable을 선언하는 방식과 동일하다.

@Composable
fun Greeting(name: String) { ... }
function Greeting( { name} ) { 
  ...
  return(
    ... // Mark-up Language (HTML)
  )
}

3. 단방향 데이터 흐름 (Unidirectional Data Flow, UDF)

데이터는 부모(위) → 자식(아래)으로, 이벤트는 자식 → 위로 콜백 형태로 전달된다.
애초에 Compose를 공부하면서 공부했던 State Hoisting 개념이 React에서 나온 것이었는데,
이 개념은 “하위 컴포넌트의 상태를 상위 컴포넌트로 이동시켜 관리하자”라는 것이다.

이로써 “상태를 가지는 객체(부모)“와 “Stateless한 객체(자식)“이 분리되고,
여기서 Stateless한 자식 객체는 테스트나 재사용하기에 좋은 구조를 가지게 된다.

실무적인 관점에서도 데이터가 무조건 위에서 아래로 흐르기때문에 (구조에 익숙하다면) 버그를 추적하기도 쉽고,
특히 동일한 데이터를 여러 UI에서 공유해야할 때 SSOT 유지를 위해 필수적인 내용이었다.


Compose와의 공통점

아무래도 같은 선언형 UI이면서 같은 철학을 공유하다보니, 대부분의 개념들이 1

매핑되었다:

ComposeReact비고
@Composable fun Foo()function Foo()대문자로 시작해야 하는 것까지 같음
remember { mutableStateOf(x) }useState(x)화면 재구성(갱신) 시에도 보존되어야하는 값들
remember(key) { compute() }useMemo(compute, [key])
derivedStateOfuseMemo파생 상태
LaunchedEffect(key) { }useEffect(fn, [key])키 바뀌면 재실행
CompositionLocalContextprop drilling 탈출구
state hoistingstate lifting같은 개념, 같은 이름
recompositionre-render특정 조건이 만족되었을 때, 화면을 바뀐 state 기반으로 갱신

다만 아직 공부중이기에, 표에 나온 React의 모든 요소들을 보지는 못하였다.


Compose와의 차이점

다만 공부하면서 “이 지점은 갈라지니 주의해라”라는 내용들도 있었다.
사실 공통점보다는 차이점에 더욱 주의하면서 공부했다.
소거법으로, 차이점 위주로 공부하면 나머지는 대부분 겹치는 것 같아서…

1. 재구성 범위

Compose는 컴파일러를 거치므로 “이 컴포저블이 어떤 State와 관련이 있는지”를 추적할 수 있고,
따라서 Recomposition이 발생할 때에는 “Recomposition을 유발한 State와 관련 있는 놈들”만 골라서 갱신할 수 있다.
즉, 화면이 수십 개의 UI 요소들로 구성되어있다 하더라도, 어떤 State 하나가 바뀌었다고 모든 화면의 요소들이 파괴되고 재생성되지 않는다는 것이다.

그러나 React는 그런 측면에서는 부족하여, 사람의 수동 최적화가 필요하다 — 혹은 React Compiler가 있다고는 하는데 찾아보지는 못했다.
화면 Rerender시 React가 모든 DOM을 파괴하고 다시 그리는 것은 아니지만,

  1. State가 바뀐 컴포넌트와 그 하위 트리의 함수를 다시 실행해서
  2. 일단 임시 공간에다가 다시 그려보고 (새 가상 DOM을 메모리에서 쌓아보고)
  3. 이전 결과와 diff 비교해본 뒤
  4. 바뀐 DOM만 patch하므로,

자칫 잘못하면 불필요한 CPU 자원이 낭비될 수 있다.

2. Rules of Hook

또다른 큰 차이는, React와 Compose에서 ‘Slot’을 어떻게 소비하느냐의 차이이다 — Slot은 State가 들어갈 위치 정도의 느낌이다.

Compose는 컴파일러를 거치므로, 어느 위치에서 어떤 State가 소비되어야하는지를 파악해둔다.
그리고 런타임에서 일종의 Slot Table을 조회하여 알맞은 State를 끼워넣는 방식이 가능하므로, State 선언 순서나 Slot의 등장 순서가 크게 중요하지 않다.

그러나 React는 순수한 JavaScript 함수이므로 컴파일러를 거치지 않고 바로바로 실행된다.
이때 런타임 시 Slot을 구분하기위해 ‘호출 순서’에 의존할 수 밖에 없다:

// 컴포넌트 인스턴스마다 슬롯 리스트가 하나
hooks = [ 'kim', 'kim@x.com', 42 ]
cursor = 0                        // 렌더 시작 때 0으로 리셋

function useState(init) {
  const slot = hooks[cursor];     // 커서 위치를 읽고
  cursor++;                       // 한 칸 전진. 끝.
  return [slot, setter];
}

function Form({ showEmail }) {
  const [name, setName] = useState('');        // 슬롯 0: name = "kim"
  if (showEmail) {
    const [email, setEmail] = useState('');    // 슬롯 1 : email = 'kim@x.com'? 소비될까?
  }
  const [age, setAge] = useState(0);           // 슬롯 ?
}

만약 위 예시에서 showEmailtrue여서 State를 소비했다면 age = 42가 되겠지만,
false여서 State 소비 없이 다음 라인으로 넘어가게 된다면 age = 'kim@x.com'이 되어버린다!

같은 이유로, 반복문도 ‘몇 차례 반복할지 알 수 없으므로(중간에 끊든, 반복 횟수가 변수든)’
State가 몇 차례 소비될거라고 단언할 수 없기때문에?
특히나 타입 안정성에 취약한 JS에서는 문제가 발생하는 것이다 — 심지어 위 사례에서는 Null조차도 던지지 않는다.

한가지 다행인 점이라면, 각 컴포넌트는 자신만의 slot list를 가지므로, 컴포넌트를 잘 분리하여 이 문제를 최소화할 수 있다는 점이다.

3. 상태는 바꾸는 것이 아니라 ‘갈아끼우는 것’

Array, 혹은 Object 갱신과 관련된 내용이다.

Compose에서는 MutalbeStateListOf를 사용하여, 동적으로 UI에 반영되는 리스트의 항목을 반영할 수 있다.
이것도 내부적으로 스냅샷 어쩌고 관련해서 내용이 깊어지기는 하는데,
아무튼 “리스트가 바뀌면 → 리스트가 바뀌었다고 알림 → 화면 갱신”이 자연스럽게 일어나는 구조이다.

하지만 어떤 배열이나 오브젝트에 새로운 요소가 추가되거나 삭제되더라도, React가 이를 알아차릴 방법이 없다.
React가 리렌더를 결정하는 트리거는 단 하나, “Setter의 호출 && 그 결과 실제 State의 변화가 있었는가”이다.

const [arr, setArr] = useState(['a', 'b']);

// ❌: Setter 호출이 아니므로 Rerender 발생 없음
arr.push('c');

// ❌: Setter는 불렀는데, arr값(즉 배열의 주소값)은 바뀌지 않음
// arr.push를 백날 해봤자 어차피 arr는 '주소값'이므로 변하지 않는다.
arr.push('c');
setArr(arr);

// ✅: [...arr, 'c'] = "arr의 모든 요소, 그리고 c"
// 새로운 배열을 생성하고 참조시켰으므로 arr와는 다름, Rerender 발생
setArr([...arr, 'c']);

때문에 단순히 배열의 값을 조작하는건 아무 의미가 없고,
아예 새로운 배열을 만들어서 참조 주소를 바꿔버려야 우리가 생각한 동작이 발생한다.


결론

많은 부분이 Compose와 비슷했기에, 나도 모르게 계속 두 라이브러리를 비교하면서 공부하였다.
두 라이브러리들이 해결하고자 했던 문제나 해결 방식, 공유하는 철학들이 너무 익숙했기에 이해하는데 문제는 없었고,
오히려 Compose를 공부하면서 까먹었었던 내용이나 더 깊은 내용까지 함께 공부했던 것 같다.

이제 React라는 라이브러리에 대해 조금은 이해도가 생겼으니, 강의를 따라가며 차근차근 정리해보고자 한다.
그 전에, 기초 JS 문법부터 한번 정리하고…

링크가 복사되었습니다