github

Diff 라인 성능 개선을 위한 험난한 여정 (새 탭에서 열림)

GitHub는 대규모 풀 리퀘스트에서도 Files changed 탭의 반응성과 안정성을 유지하기 위해 단일 해결책이 아닌 여러 최적화 전략을 적용했다. 특히 diff 라인마다 생성되는 DOM 요소, React 컴포넌트, 이벤트 핸들러를 줄여 메모리 사용량과 입력 지연을 낮추고, 가장 큰 변경 사항에는 가상화를 적용해 렌더링 범위를 제한하는 방향을 택했다. 핵심 교훈은 작은 구조적 최적화도 수천 개의 diff 라인에 누적되면 큰 성능 개선으로 이어진다는 것이다.

대규모 풀 리퀘스트에서 성능이 어려운 이유

  • GitHub의 풀 리퀘스트는 한 줄 수정부터 수천 개 파일과 수백만 줄을 포함하는 변경까지 규모 편차가 매우 크다.
  • 일반적인 풀 리퀘스트는 빠르게 동작했지만, 대규모 변경에서는 다음 문제가 발생했다.
    • JavaScript 힙이 극단적인 경우 1GB를 초과
    • DOM 노드 수가 40만 개 이상으로 증가
    • 페이지 상호작용이 매우 느려지거나 사실상 사용할 수 없게 됨
    • 입력 후 다음 화면이 표시되기까지의 시간인 INP가 허용 수준을 초과
  • 모든 기능과 브라우저의 기본 동작을 유지하면서 최악의 경우까지 해결하는 단일 기법은 현실적인 한계가 있었다.

풀 리퀘스트 규모별 최적화 전략

GitHub는 변경 규모와 복잡도에 따라 서로 다른 전략을 조합했다.

  • diff 라인 컴포넌트 최적화
    • 대부분의 풀 리퀘스트에서 기본 diff 화면을 빠르게 유지
    • 중간 및 대규모 리뷰에서도 브라우저의 기본 find-in-page 같은 동작을 보존
  • 가상화를 통한 점진적 성능 저하
    • 가장 큰 풀 리퀘스트에서는 한 번에 렌더링하는 콘텐츠 양을 제한
    • 모든 내용을 동시에 DOM에 올리지 않아 응답성과 안정성을 우선
  • 기반 컴포넌트와 렌더링 개선
    • 특정 모드에 관계없이 모든 크기의 풀 리퀘스트에 누적 효과를 제공
    • 렌더링 구조 자체를 단순화해 메모리와 상호작용 비용을 줄임

초기 목표와 측정 지표

최적화 작업의 목표는 단순히 평균 속도를 높이는 데 그치지 않았다.

  • JavaScript 힙 크기와 메모리 사용량 감소
  • DOM 노드 수 감소
  • 평균 INP 개선
  • 특히 최악의 사용자 경험을 나타내는 p95와 p99 INP 대폭 개선
  • 이를 위해 상태, HTML 요소, JavaScript 코드, React 컴포넌트 수를 전반적으로 줄이는 단순화 전략을 채택

v1의 문제: diff 라인당 높은 렌더링 비용

React로 처음 diff 화면을 구현한 v1은 작은 재사용 컴포넌트를 많이 조합하는 구조였다.

  • unified 뷰의 diff 한 줄에는 최소 약 10개의 DOM 요소가 필요했다.
  • split 뷰에서는 한 줄당 약 15개의 DOM 요소가 필요했다.
  • 구문 강조를 적용하면 추가 <span> 요소가 더해져 DOM 수가 증가했다.
  • React 계층에서도 다음과 같은 컴포넌트가 생성됐다.
    • unified 뷰: 한 줄당 최소 8개 컴포넌트
    • split 뷰: 한 줄당 최소 13개 컴포넌트
  • 댓글, hover, focus 등 추가 상태가 활성화되면 컴포넌트 수는 더 늘어났다.
  • 작은 컴포넌트마다 React 이벤트 핸들러를 5~6개씩 연결하는 경우가 많았다.
  • 결과적으로 diff 한 줄에 20개 이상의 이벤트 핸들러가 붙을 수 있었고, 이를 수천 줄에 적용하면서 비용이 급격히 커졌다.

v1의 한 줄당 최소 구조는 다음과 같았다.

  • DOM 요소 10~15개
  • React 컴포넌트 8~13개
  • React 이벤트 핸들러 20개 이상
  • 다수의 작은 재사용 컴포넌트

이 구조는 일반적인 규모에서는 문제가 없었지만, 데이터 크기가 사실상 제한되지 않는 대규모 풀 리퀘스트에서는 변경 줄 수가 늘수록 INP와 JavaScript 힙 사용량이 함께 악화됐다.

v2의 방향: 작은 변경을 대규모로 누적

v2에서는 눈에 띄지 않는 HTML 구조까지 점검해 diff 라인당 비용을 줄였다.

  • 줄 번호 셀에 불필요하게 포함되어 있던 <code> 태그를 제거했다.
  • diff 한 줄에서 DOM 노드 2개를 줄이는 것은 개별적으로는 작은 개선처럼 보인다.
  • 그러나 10,000줄을 렌더링하면 DOM 노드 20,000개를 제거하는 효과가 발생한다.
  • 이처럼 줄 단위의 사소한 최적화도 대규모 데이터에서는 메모리 사용량과 렌더링 비용에 크게 누적된다.
  • 성능 개선에서는 큰 기능 변경만큼 불필요한 태그, 컴포넌트, 이벤트 핸들러를 하나씩 제거하는 작업도 중요하다.

실용적인 결론

대규모 목록이나 코드 diff를 렌더링할 때는 처음부터 최악의 데이터 규모를 고려해야 한다. 컴포넌트를 잘게 나누는 구조가 유지보수에는 유리할 수 있지만, 각 요소에 상태와 이벤트 핸들러를 추가하면 수천 개 항목에서 큰 비용이 된다. 따라서 DOM 구조를 단순화하고, 항목별 렌더링 비용을 측정하며, 극단적인 규모에는 가상화를 적용하는 조합이 효과적이다.