Ray Book
에세이

React는 생각보다 대충 계산한다

O(n³)을 O(n)으로 만든 React의 Diffing 알고리즘. 완벽한 정답 대신 충분히 좋은 답을 고르는 공학의 본질을 짚어본다.

essayreactalgorithmengineeringopinion

정답은 있는데, 쓸 수가 없다

두 개의 트리를 비교해서 최소한의 변환을 찾는 문제. 컴퓨터 과학에서 이미 풀린 문제다. 답은 있다. 다만 그 답의 시간 복잡도가 O(n³)이라는 게 문제다.

노드가 1,000개면 10억 번의 연산이다. UI를 매 프레임 갱신해야 하는 브라우저에서 이건 쓸 수 없는 답이다. 수학적으로는 정답이지만, 현실에서는 불가능한 답.

여기서 질문이 생긴다. 쓸 수 없는 정답은 정답인가? 이론에서는 정답이지만, 공학에서는 아니다. 공학은 현실에 발을 딛고 있는 학문이니까.

완벽한 답을 버리는 결정

React 팀은 이 문제 앞에서 이론적 완전성을 포기했다. 포기라는 단어가 부정적으로 들릴 수 있지만, 사실 이건 포기가 아니라 결단이다. 현실의 패턴을 관찰하고, 두 가지 휴리스틱 가정을 세웠다.

첫째, 타입이 다르면 다른 트리다. <div><section>으로 바뀌면, 자식을 하나하나 비교하지 않고 통째로 교체한다.

둘째, 같은 레벨의 자식은 key로 식별한다. 리스트에서 항목 순서가 바뀌었을 때, 내용을 대조하는 대신 key를 보고 "이 녀석이 저기로 갔구나"를 바로 파악한다.

이 두 가정만으로 재조정 알고리즘의 복잡도는 O(n³)에서 O(n)이 된다. 정확도를 약간 포기한 대가로, 노드 1,000개 기준 백만 배의 성능을 얻은 거다.

React 레거시 문서의 Reconciliation 페이지. 1000개 엘리먼트면 10억 번 비교가 필요해 너무 비싸다는 문장과 두 가정이 적혀 있다. 상단에 더 이상 갱신되지 않는 사이트라는 배너가 있다
React 팀이 직접 적어둔 문장. 10억 번 비교, 두 가지 가정까지 본문 서술 그대로다. 지금은 아카이브된 레거시 문서라 상단에 안내 배너가 붙어 있다. legacy.reactjs.org

완벽을 고집하다 아무것도 못 내놓는다는 말이 있다. 완벽한 답을 고집하면 아무것도 만들 수 없다. 불완전하지만 충분히 좋은 답을 선택할 수 있느냐가 공학의 첫 번째 질문이다.

틀릴 수 있다는 걸 알면서도

여기서 진짜 흥미로운 건, React 팀이 이 가정이 틀릴 수 있다는 걸 알고 있었다는 거다.

타입이 달라졌는데 내부 구조가 거의 같은 경우도 분명 있다. 그런 경우 React는 불필요하게 트리를 통째로 다시 만든다. 이론적으로는 오답이다. 하지만 React 팀이 던진 질문은 "이게 맞느냐"가 아니었다. "실제로 이런 일이 얼마나 자주 생기느냐"였다.

이건 과학과 공학의 근본적인 시선 차이다. 과학은 진리를 묻는다. "모든 경우에 참인 것은 무엇인가?" 공학은 확률을 묻는다. "충분히 많은 경우에 작동하는 것은 무엇인가?"

과학자는 예외 하나에 이론을 수정한다. 공학자는 예외의 빈도를 보고 감수할지 말지를 판단한다. 둘 다 세상을 이해하는 방법이지만, 방향이 다르다. 공학자 폰 카르만의 표현을 빌리면, 과학은 세상을 있는 그대로 기술하려 하고, 공학은 세상을 있어야 할 모습으로 만들려 한다.

React의 두 가정은 "세상은 대체로 이렇게 생겼다"는 믿음 위에 서 있다. 그리고 그 믿음이 틀릴 수 있다는 걸 인정한다. React의 휴리스틱이 그런 쪽이다.

key, 판단을 개발자에게 넘기는 지점

key prop은 이 철학이 개발자에게까지 확장된 지점이다.

{items.map(item => (
  <ListItem key={item.id} data={item} />
))}

React는 리스트 항목의 동일성을 완벽하게 추론할 수 없다. 그래서 그 판단을 개발자에게 넘긴다. key만 알려주면 나머지는 React가 알아서 처리한다.

이건 작은 API 하나로 보이지만, 사실 꽤 근본적인 질문의 답이다. 기계는 어디까지 알 수 있는가? 기계가 알 수 없는 영역은 어떻게 처리해야 하는가?

React가 택한 답은 겸손한 쪽이다. 추론을 포기하고 개발자에게 정보를 받는다. 완벽한 자동화를 약속하는 대신, 자기 한계를 인정하고 인간과 협력하는 쪽을 택한 거다. 전부 자동으로 해내겠다고 고집하는 것보다, 적당한 선에서 책임을 나누는 쪽이 더 나은 시스템을 만든다. key라는 작은 prop이 그걸 보여준다.

이 패턴은 어디에나 있다

이런 사고방식은 React만의 것이 아니다. 오히려 잘 만들어진 시스템이라면 거의 예외 없이 이 패턴을 따른다.

TCP의 혼잡 제어는 네트워크 상태를 완벽히 파악하지 않는다. 패킷 손실이라는 단서 하나로 전송 속도를 조절한다. 완벽한 정보 없이도 충분히 좋은 결정을 내리는 거다.

가비지 컬렉터는 "최근에 만들어진 객체가 먼저 죽는다"는 세대별 가설 위에서 동작한다. 모든 객체를 매번 검사하는 대신, 이 가설에 기대어 젊은 세대만 자주 훑는다. 가설이 틀리는 경우도 있지만, 맞는 경우가 압도적이니까 성립한다.

데이터베이스의 쿼리 옵티마이저도 마찬가지다. 가능한 실행 계획을 전부 시도하는 건 불가능하니까, 통계 기반의 추정으로 충분히 좋은 경로를 고른다.

패턴은 동일하다. 현실을 관찰하고, 규칙성을 발견하고, 그 규칙이 깨질 확률을 감수하면서도 실용적인 해법을 택한다. 그리고 규칙이 깨지는 드문 경우를 위해 탈출구를 마련해둔다. 이 탈출구의 존재가 중요하다. 자기 방식이 완벽하지 않다는 걸 인정해야 탈출구를 설계할 수 있으니까.

충분히 좋은 답의 가치

통계학자 George Box는 "모든 모델은 틀렸다, 하지만 그중 일부는 쓸모 있다"고 했다. 공학적 사고가 뭔지 이 한 문장에 다 들어 있다.

React의 휴리스틱도, TCP의 혼잡 제어도, 가비지 컬렉터의 세대별 가설도, 전부 "틀린 모델"이다. 하지만 현실에서 쓸모 있는 모델이다. 이 구분을 할 수 있느냐가 엔지니어의 역량이다.

모든 경우를 커버하는 알고리즘을 만드느라 아무것도 배포하지 못하는 것보다, 대부분의 경우에 잘 작동하는 알고리즘으로 사용자에게 가치를 전달하는 것. React가 거대한 컴포넌트 트리를 매번 갱신하고도 버티는 건, 완벽한 답이 아니라 충분히 좋은 답을 선택했기 때문이다.

그리고 이게 꼭 코드에만 해당하는 얘기도 아니다. 설계를 하든, 제품을 만들든, 어떤 결정을 내리든, 우리는 항상 불완전한 정보 위에서 판단을 내려야 한다. 완벽한 정보가 모일 때까지 기다리면 영원히 아무것도 하지 못한다. 불확실함 속에서도 "지금 이 조건에서 가장 합리적인 것"을 골라 앞으로 나아가는 것. 그게 공학이 하는 일이고, 사실 우리가 매일 하는 일이기도 하다.

참고 자료

  • React 공식 문서, Reconciliation, O(n³)에서 O(n)으로 내려온 두 가지 가정의 원문