2 / 2

02. React 탄생 이야기

예상 시간: 5분

02. React 탄생 이야기

이 문서에서 배우는 것

  • 2011년 Facebook이 겪었던 실제 기술적 문제
  • "읽지 않은 메시지" 버그가 왜 구조적으로 해결 불가능했는지
  • Jordan Walke가 PHP(XHP)에서 어떤 영감을 얻었는지
  • FaxJS에서 React로 발전한 과정
  • React가 오픈소스로 공개된 후 초기 반응과 확산 과정

PM의 요구사항 (2011년, 실화)

"채팅 알림 뱃지에 읽지 않은 메시지가 1개라고 뜨는데, 클릭해서 다 읽어도 사라지지 않아요. 고쳐주세요."

이 한 줄의 버그 리포트가, 결국 React를 탄생시켰습니다.


2011년 Facebook 기술 스택

당시 Facebook의 기술 스택은 두 세계로 나뉘어 있었습니다:

┌──────────────────────────────────────────────────┐
│                  Facebook (2011)                  │
├──────────────────────┬───────────────────────────┤
│      서버 사이드      │       클라이언트 사이드     │
│                      │                           │
│   PHP + XHP          │   순수 JavaScript (Bolt)   │
│                      │                           │
│  • 서버에서 HTML 생성  │  • 브라우저에서 DOM 조작    │
│  • 컴포넌트 개념 있음  │  • 명령형 코드            │
│  • 데이터→HTML 자동    │  • 수동 DOM 업데이트       │
│  • 매 요청마다 새로 생성│  • 상태 관리는 개발자 몫    │
└──────────────────────┴───────────────────────────┘

XHP — PHP 쪽의 컴포넌트 시스템

Facebook은 PHP에 XHP라는 확장을 만들어 쓰고 있었습니다:

// XHP — PHP 안에서 HTML을 컴포넌트처럼 사용
class :ui:badge extends :x:element {
  attribute int count @required;
 
  protected function render() {
    $count = $this->getAttribute('count');
    if ($count === 0) {
      return <x:frag />;  // 0이면 아무것도 안 보임
    }
    return <span class="badge">{$count}</span>;
  }
}
 
// 사용
<ui:badge count={$unreadCount} />

XHP의 장점: 데이터($unreadCount)가 바뀌면 다음 요청에서 HTML을 처음부터 다시 만들기 때문에, 동기화 문제가 없었습니다.

Bolt — 클라이언트 쪽의 JavaScript

브라우저에서의 인터랙션은 Bolt라는 내부 프레임워크로 처리했습니다:

// Bolt — 명령형 DOM 조작
var ChatBadge = {
  update: function(unreadCount) {
    var badge = document.getElementById('chat-badge');
    if (unreadCount > 0) {
      badge.style.display = 'inline';
      badge.textContent = unreadCount;
    } else {
      badge.style.display = 'none';
    }
  }
};
 
// 메시지 읽음 처리 시
MessageStore.onRead(function(messageId) {
  var count = MessageStore.getUnreadCount();
  ChatBadge.update(count);          // ← 직접 업데이트
  HeaderNotification.update(count); // ← 여기도
  TabTitle.update(count);           // ← 여기도
  // ... 더 있을 수 있음
});

유명한 버그: 사라지지 않는 알림 뱃지

문제의 화면

┌────────────────────────────────────────────────────┐
│  Facebook  [🔍 검색]  [홈] [👤] [💬1] [🔔3] [▼]    │
│                                      ↑              │
│                              이 "1"이 사라지지 않음   │
├────────────────────────────────────────────────────┤
│                                                    │
│   ┌─ 채팅 창 ──────────┐                           │
│   │ John: Hey!         │  ← 이미 읽었는데          │
│   │ (이미 읽음 상태)    │     뱃지는 여전히 "1"      │
│   └────────────────────┘                           │
│                                                    │
└────────────────────────────────────────────────────┘

왜 이 버그가 발생했을까?

메시지 수신 이벤트 발생 시 업데이트해야 하는 곳:
 
  ① 채팅 뱃지 숫자          → Chat 팀 담당
  ② 상단 알림 아이콘         → Notifications 팀 담당
  ③ 브라우저 탭 타이틀       → Platform 팀 담당
  ④ 채팅 목록의 읽지 않음 표시 → Chat 팀 담당
  ⑤ 채팅 창 내부 메시지 상태  → Chat 팀 담당
  ⑥ 사이드바 친구 목록 상태   → Social 팀 담당
  ⑦ 모바일 웹 뷰의 뱃지      → Mobile 팀 담당
// 메시지 읽음 처리 — 여러 팀이 각자 작성한 코드
MessageActions.markAsRead = function(threadId) {
  // 서버에 읽음 상태 전송
  API.post('/messages/read', { thread: threadId });
 
  // 로컬 상태 업데이트
  MessageStore.markRead(threadId);
  var count = MessageStore.getUnreadCount();
 
  // ① Chat 팀이 작성
  ChatBadge.update(count);
 
  // ② Notifications 팀이 작성
  NotificationIcon.refresh();
 
  // ③ Platform 팀이 작성
  TabTitle.setCount(count);
 
  // ④ Chat 팀이 작성
  ChatList.markThreadRead(threadId);
 
  // ⑤ Chat 팀이 작성
  ChatWindow.updateReadStatus(threadId);
 
  // ⑥ 새로 추가된 사이드바 — 누군가 여기에 추가하는 걸 잊음!
  // SidebarFriends.updateStatus(threadId);  ← 빠뜨림!
 
  // ⑦ 모바일 웹 뷰 — 아직 연동 안 됨
  // MobileView.sync();  ← 빠뜨림!
};

구조적 문제

이건 개발자 한 명의 실수가 아니었습니다. 구조적으로 발생할 수밖에 없는 문제였습니다:

문제의 근본 원인:
 
1. 새 UI 영역이 추가될 때마다, 기존의 모든 이벤트 핸들러에
   새 영역 업데이트 코드를 추가해야 함
 
2. 각 영역을 다른 팀이 담당하므로, 다른 팀의 코드에
   자기 영역 업데이트를 추가해달라고 요청해야 함
 
3. 하나라도 빠뜨리면 → 불일치 버그
 
4. 이 패턴이 메시지뿐 아니라 좋아요, 댓글, 친구 요청,
   그룹 알림 등 모든 기능에 존재

고치면 또 다른 곳에서 터지고, 그걸 고치면 또 다른 곳에서 터지는 두더지 잡기 게임이었습니다.


Jordan Walke의 아이디어

Jordan Walke는 Facebook의 광고 팀에서 일하던 엔지니어였습니다. 그는 이 문제를 보면서 한 가지를 깨달았습니다.

PHP(XHP)에서 온 영감

서버(XHP):
  요청이 올 때마다 → 데이터로 HTML을 처음부터 새로 만듦
  → 이전 상태와 동기화할 필요 없음
  → 그래서 동기화 버그가 없음
 
브라우저(Bolt):
  이벤트가 올 때마다 → 기존 DOM을 부분적으로 수정
  → 어디를 수정해야 하는지 추적 필요
  → 빠뜨리면 동기화 버그

Jordan의 질문:

"브라우저에서도 XHP처럼 매번 처음부터 다시 그리면 되지 않을까?"

단순하지만 강력한 발상

기존 방식:
  상태 변경 → 바뀐 부분을 찾아서 DOM 수정 (명령형)
  → 개발자가 "어디를 바꿀지" 알아야 함
 
Jordan의 방식:
  상태 변경 → 전체 UI를 새로 "선언" → 이전과 비교 → 바뀐 부분만 실제 반영
  → 개발자는 "최종 상태"만 선언하면 됨

FaxJS: React의 프로토타입

2011년, Jordan Walke는 이 아이디어를 FaxJS라는 프로토타입으로 구현했습니다.

기존 Bolt 코드 (명령형)

// Bolt 방식 — "어떻게 바꿀지" 일일이 지시
var NotificationBadge = {
  element: null,
  count: 0,
 
  init: function(el) {
    this.element = el;
    this.render();
  },
 
  setCount: function(newCount) {
    var oldCount = this.count;
    this.count = newCount;
 
    // 변경사항을 직접 추적하고 DOM을 수동 업데이트
    if (oldCount === 0 && newCount > 0) {
      this.element.style.display = 'inline';
      this.element.textContent = newCount;
      this.element.classList.add('badge-pulse');
    } else if (oldCount > 0 && newCount === 0) {
      this.element.style.display = 'none';
      this.element.classList.remove('badge-pulse');
    } else if (newCount !== oldCount) {
      this.element.textContent = newCount;
      this.element.classList.add('badge-pulse');
      setTimeout(function() {
        this.element.classList.remove('badge-pulse');
      }.bind(this), 300);
    }
    // 상태별 분기가 점점 복잡해짐...
  }
};

FaxJS 코드 (선언형)

// FaxJS 방식 — "어떻게 보여야 하는지"만 선언
var NotificationBadge = FaxJS.createComponent({
  getInitialState: function() {
    return { count: 0 };
  },
 
  render: function() {
    // 상태에 따른 최종 모습만 선언
    if (this.state.count === 0) {
      return FaxJS.Div({ style: { display: 'none' } });
    }
 
    return FaxJS.Span({
      className: 'badge',
      content: this.state.count
    });
  }
 
  // DOM 업데이트? FaxJS가 알아서 함
  // 이전 렌더와 비교? FaxJS가 알아서 함
  // 애니메이션 분기? CSS transition으로 처리
});

핵심 차이

Bolt (명령형):
  "count가 0에서 1로 바뀌면 display를 inline으로 바꾸고,
   1에서 0으로 바뀌면 none으로 바꾸고,
   1에서 2로 바뀌면 textContent만 바꾸고..."
   → 모든 전환(transition)을 개발자가 직접 관리
 
FaxJS (선언적):
  "count가 0이면 안 보이고, 0이 아니면 숫자를 보여줘"
   → 최종 상태만 선언, 전환은 프레임워크가 관리

FaxJS → React로 발전

2011         2012 초               2012 중          2012 말        2013.05
  │            │                     │               │              │
  ▼            ▼                     ▼               ▼              ▼
FaxJS       Facebook 내부       뉴스피드 적용     Instagram      오픈소스
프로토타입    검색에 첫 적용      + JSX 도입       전면 도입       공개

주요 이정표

2011년: FaxJS 프로토타입

  • Jordan Walke가 개인 프로젝트로 시작
  • "매번 다시 그리기" 아이디어 검증

2012년 초: Facebook 검색에 적용

  • Facebook 내부에서 첫 실전 테스트
  • 검색 결과 UI에 적용 → 버그가 크게 줄어듦
  • 내부 이름이 "React"로 변경

2012년 중: 뉴스피드에 적용 + JSX 도입

  • Facebook의 핵심 기능인 뉴스피드에 도입
  • JSX 문법 추가 — JavaScript 안에서 HTML과 비슷한 문법 사용
// JSX 이전 (React.createElement)
React.createElement('div', { className: 'badge' },
  React.createElement('span', null, count)
);
 
// JSX 도입 후
<div className="badge">
  <span>{count}</span>
</div>

2012년 말: Instagram 전면 도입

  • Facebook이 Instagram을 인수한 후
  • Instagram 웹 버전을 React로 전면 재작성
  • "외부 프로젝트에서도 잘 동작한다"는 확인

2013년 5월: JSConf에서 오픈소스 공개

  • Pete Hunt가 JSConf US에서 React 발표
  • GitHub에 공개

초기 반응: "미친 거 아니야?"

React가 공개되었을 때, 커뮤니티의 반응은 대부분 부정적이었습니다.

비판 1: "HTML을 JavaScript 안에 넣는다고?"

// 2013년의 개발자들이 본 것:
function Badge({ count }) {
  return (
    <div className="badge">     {/* ← 이게 JS 파일 안에?! */}
      <span>{count}</span>
    </div>
  );
}
2013년 커뮤니티 반응:
 
"SoC(관심사의 분리) 원칙을 완전히 위반하잖아"
"HTML은 .html에, JS는 .js에 있어야 하는 거 아닌가?"
"이건 PHP 스파게티 코드로 돌아가는 것과 같다"
"Facebook이 또 이상한 걸 만들었네"

비판 2: "Virtual DOM이라니, 느리지 않아?"

비판: "실제 DOM을 직접 수정하는 게 더 빠르지 않나?
       매번 전체를 다시 그리고 비교한다고?"
 
실제: DOM 조작 자체가 비싼 연산.
      Virtual DOM은 JavaScript 객체 비교(빠름)를 먼저 하고
      실제 DOM 변경은 최소화함.
 
      그리고 성능보다 중요한 건 "버그 없는 코드"였음.

비판 3: "기존의 MVC 패턴이 있는데 왜?"

2013년 인기 프레임워크들:
  - Angular 1.x  — Google의 MVC 프레임워크
  - Backbone.js  — 경량 MVC
  - Ember.js     — Convention over Configuration
 
이들은 모두 Model-View-Controller 패턴을 따름
React는 "V(View)만 담당한다"고 했음
 
비판: "프레임워크도 아닌 게 뭘 하겠다는 거야?"

하지만... 써본 사람들의 이야기

2013년 말 ~ 2014년:
 
"처음엔 JSX가 이상해 보였는데, 일주일 쓰니까
 이전으로 돌아갈 수 없게 됐다"
 
"뉴스피드에서 매주 터지던 상태 버그가 React 적용 후 사라졌다"
 
"새 기능 추가할 때 기존 코드가 깨질까 두려웠는데,
 React에서는 컴포넌트만 추가하면 되니까 두려움이 없다"
 
"jQuery로 6시간 걸리던 걸 React로 1시간에 했다.
 코드 양이 줄어서가 아니라, 생각할 게 줄어서."

코드로 보는 전환: jQuery vs React (2014년 기준)

채팅 뱃지 — jQuery

// 여러 이벤트에서 각각 DOM을 직접 조작
$(document).on('newMessage', function(e, data) {
  var $badge = $('#chat-badge');
  var currentCount = parseInt($badge.text()) || 0;
  var newCount = currentCount + 1;
 
  $badge.text(newCount).show().addClass('pulse');
  $('#header-count').text('(' + newCount + ')');
  document.title = '(' + newCount + ') Facebook';
 
  setTimeout(function() {
    $badge.removeClass('pulse');
  }, 300);
});
 
$(document).on('messageRead', function(e, data) {
  var $badge = $('#chat-badge');
  var currentCount = parseInt($badge.text()) || 0;
  var newCount = Math.max(0, currentCount - 1);
 
  if (newCount === 0) {
    $badge.hide();
    document.title = 'Facebook';
  } else {
    $badge.text(newCount);
    document.title = '(' + newCount + ') Facebook';
  }
  $('#header-count').text(newCount > 0 ? '(' + newCount + ')' : '');
 
  // 채팅 목록도 업데이트...
  $('#thread-' + data.threadId).removeClass('unread');
});
 
// 새 영역 추가 시: 위의 두 핸들러 모두에 코드 추가 필요!

채팅 뱃지 — React

function ChatBadge({ unreadCount }) {
  // 선언적: 상태에 따른 최종 모습만 정의
  if (unreadCount === 0) return null;
 
  return <span className="badge">{unreadCount}</span>;
}
 
function Header({ unreadCount }) {
  return (
    <header>
      <h1>Facebook {unreadCount > 0 && `(${unreadCount})`}</h1>
      <nav>
        <ChatBadge unreadCount={unreadCount} />
      </nav>
    </header>
  );
}
 
function App() {
  const [unreadCount, setUnreadCount] = useState(0);
 
  // 이벤트 처리: 상태만 변경하면 모든 UI가 자동으로 맞춰짐
  useEffect(() => {
    socket.on('newMessage', () => setUnreadCount(c => c + 1));
    socket.on('messageRead', () => setUnreadCount(c => Math.max(0, c - 1)));
  }, []);
 
  // unreadCount가 바뀌면 Header, ChatBadge, 탭 타이틀 등
  // 모든 곳이 자동으로 업데이트됨
  return <Header unreadCount={unreadCount} />;
}
jQuery: 이벤트 2개 × DOM 영역 4개 = 8곳에서 수동 업데이트
React:  상태 1개 변경 → 모든 UI 자동 동기화
 
새 영역 추가 시:
  jQuery → 모든 이벤트 핸들러에 새 DOM 조작 코드 추가
  React  → 새 컴포넌트만 추가하면 끝

Facebook 내부 도입 타임라인

2012.01  검색 UI에 첫 적용
         → "확실히 버그가 줄었다"
 
2012.06  뉴스피드에 적용
         → "매주 터지던 상태 버그가 사라졌다"
 
2012.09  Instagram 웹 전면 도입
         → "인수 후 웹사이트를 React로 재작성"
 
2013.03  Facebook 내부 대부분의 신규 프로젝트가 React 사용
         → "이제 Bolt로 새 프로젝트를 시작하는 팀이 없다"
 
2013.05  JSConf US에서 오픈소스 공개
         → Pete Hunt: "React의 요점은 성능이 아니라
            예측 가능한 코드를 작성할 수 있게 하는 것이다"

시니어 개발자의 한마디

후배: "React가 처음에 욕을 엄청 먹었다면서요?"

시니어: "응. 'HTML을 JS에 넣는다고? 미쳤다'는 반응이 대부분이었어. 그런데 면접에서 React 경험을 물어보기 시작한 건 공개 후 2년도 안 돼서부터야."

후배: "왜 그렇게 빨리 퍼졌을까요?"

시니어: "React가 해결하는 문제가 진짜였으니까. Facebook만의 문제가 아니라, 웹 앱을 만드는 모든 팀이 같은 '상태-UI 동기화' 문제로 고통받고 있었거든. '이론적으로 나쁘다'보다 '실제로 버그가 줄었다'가 더 강력했어."


핵심 정리

  1. Facebook의 구조적 문제: 하나의 이벤트(메시지 읽음)가 7개 이상의 UI 영역에 영향 → 각 영역을 다른 팀이 관리 → 동기화 버그 불가피
  2. Jordan Walke의 통찰: PHP(XHP)처럼 매번 처음부터 다시 그리면 동기화 문제가 없다 → 이걸 브라우저에서 해보자
  3. FaxJS → React: 프로토타입에서 시작 → Facebook 내부 검증 → 오픈소스 공개
  4. 명령형 → 선언적: "어떻게 바꿔라" → "이렇게 보여야 한다"
  5. 초기 비판 → 빠른 확산: "JSX가 이상하다" → 써본 사람들이 장점을 체감 → 업계 표준
  6. 핵심 교훈: 진짜 문제를 해결하는 기술은 초기 비판을 이겨냄

다음 문서에서는 React의 JSX가 촉발한 "관심사의 분리(SoC)" 논쟁을 깊이 살펴봅니다. HTML/CSS/JS를 분리하는 것이 정말 "관심사의 분리"였을까요?