[번역] GitHub Copilot 앱에서 거대한 PR 렌더링하기
Soshy·

대규모 리팩터링이나 마이그레이션은 종종 하나의 변경사항으로 한꺼번에 반영해야 합니다.
스택형 PR(Stacked pull requests)는 작업을 더 작은 변경사항으로 나누는 좋은 방법입니다. 리뷰가 쉬워지고 팀이 더 적은 위험으로 변경사항을 배포할 수 있기 때문입니다. 하지만 이번 사례처럼 깔끔하게 나눌 수 없는 변경도 있습니다. 이 경우 하나의 PR이 매우 커지고, 리뷰 과정에서 오가는 대화까지 쌓이면서 계속 더 커집니다.
diff와 그 안의 대화가 아무리 거대하더라도 리뷰 경험은 빠르고 부드러워야 합니다. 우리는 GitHub Copilot 앱의 PR 화면을 이 요구사항에 맞춰 새로 만들었습니다.
어디까지 감당할 수 있는지 확인하기 위해 우리가 찾을 수 있는 가장 큰 PR을 열어봤습니다. 파일 2,200개, 변경된 줄 100만 개 이상, 인라인 리뷰 댓글 400개가 넘는 오픈 소스 PR이었습니다. 이처럼 극단적인 PR에서도 성능을 확보한 방법을 소개합니다.
문제의 범위
큰 diff를 빠르게 렌더링하는 방법 자체는 잘 알려져 있습니다. 행을 가상화(virtualization)하고, 마운트된 DOM을 작게 유지하며, 모든 행이 높이를 미리 알 수 있는 코드 한 줄이라는 점을 이용하면 됩니다.
어려운 것은 댓글입니다. 댓글의 높이는 마크다운이 어떻게 줄바꿈되는지, 펼칠 수 있는 섹션이 있는지, 답글 입력창이 열려 있는지, 이미지 로딩이 끝났는지 등에 따라 달라집니다. 이런 정보는 실제로 렌더링한 뒤에야 알 수 있습니다. 따라서 기존과는 다른 아키텍처가 필요합니다.
문제는 세 가지였습니다.
- 측정(Measurement). 댓글은 렌더링하기 전까지 높이를 알 수 없습니다. 이 때문에 큰 diff를 스크롤할 때도 반응성을 유지하게 해주는 기존 설계가 깨집니다.
- 데이터 파이프라인. 아무리 빠른 diff 화면이라도 데이터를 공급하는 파이프라인이 멈추거나, 이미 처리한 작업을 버려버린다면 소용이 없습니다.
- 실제로 버그를 찾는 방법. 이런 문제는 높은 부하, 특정 엔진, 특정 스크롤 위치에서만 나타납니다. 그래서 먼저 정상 상태가 무엇인지 정의하고, 화면 자체가 이를 측정하도록 계측한 뒤, 변경 → 측정 → 개선 전체 과정을 사람 없이 반복 실행했습니다.
Part 1: 가상화, 그리고 댓글이 이를 깨뜨리는 이유
첫 단계는 코드만 있는 diff가 빠르게 동작할 수 있게 해주는 기하 구조를 이해하는 것입니다. 여기에 댓글이 들어오면 그 구조만으로는 충분하지 않습니다.
큰 diff가 빠른 이유
페이지에 DOM 노드 100만 개를 올릴 수는 없습니다. 일반적인 해결책은 가상화(virtualization) 입니다. 화면에 보이는 행과 주변의 작은 여유 영역만 마운트하고, 사용자가 스크롤할 때 같은 DOM 요소를 재사용합니다. 목록은 실제로 100만 개의 행이 모두 존재하는 것처럼 동작합니다. 스크롤바 길이도 정확하고 특정 행으로 스크롤하는 기능도 작동하지만, 실제 DOM에 존재하는 행은 한 번에 약 100개뿐입니다.
이 착시가 유지되려면 어디선가 위치 정보를 제공해야 합니다. 스크롤바의 전체 높이는 모든 행 높이의 합입니다. N번째 행의 위치는 그 위에 있는 모든 행 높이의 합입니다. 특정 행으로 이동하거나, 스크롤바를 그리거나, 어떤 행이 화면 안에 있는지 판단하는 일은 모두 높이 테이블을 기반으로 한 산술 연산입니다. 처음에는 추정값으로 테이블을 만들고 실제 높이가 측정될 때마다 보정할 수 있으며, 범용 가변 높이 가상화 라이브러리들도 바로 이런 방식으로 동작합니다.
하지만 모든 행이 정해진 폰트 크기를 사용하는 코드 한 줄이라면 그럴 필요가 없습니다. 전체 테이블을 처음부터 계산할 수 있고 이후에도 변하지 않으므로, 나중에 보정할 것이 없습니다.
우리는 이것을 "페인트 전에 모든 높이를 알고 있다(all heights known before paint)"는 계약이라고 부릅니다. 우리의 diff 화면은 이 전제를 중심으로 설계되어 있습니다.
- React 컴포넌트를 행마다 만들지 않고, 재사용되는 명령형 코드 행 렌더러 사용
- 오프셋 계산을 위한 타입 배열(typed array) 기반 기하 데이터
- 백엔드가 소유하고 구조부터 먼저 스트리밍하는 diff 문서
- 정확한 "N번째 행으로 스크롤"을 제공하는 명령형 스크롤 API
이 중 어느 것도 전체 행 개수에 따라 프레임별 작업량이 커지지 않습니다. 코드만 있는 화면이라면 이 설계가 적합했고, 우리는 이 부분을 그대로 유지했습니다.
댓글은 이 계약을 어떻게 바꾸는가
이제 diff 중간에 리뷰 스레드 하나를 넣어봅시다. 높이가 얼마나 될까요?
알 수 없습니다. 실제로 렌더링하기 전에는 알 수 없습니다. 높이는 렌더링 시점에만 존재하는 요소에 따라 달라지며, 첫 렌더링 이후에도 계속 변할 수 있습니다.
- 화면 너비에 따라 다르게 줄바꿈되는 마크다운
- 사용자가 그 자리에서 펼치거나 접을 수 있는
<details>블록 - 기존 스레드 안에서 열리고 입력할수록 커지는 답글 작성기
- 제안된 변경사항 diff, 리액션, 수정 모드, 해결 여부 배너
- 로딩이 끝났을 때 높이가 달라지는 이미지와 비동기 애셋
가장 단순한 방법은 각 댓글마다 추정기를 이용해 고정 높이 공간을 미리 확보하는 것입니다. 하지만 큰 PR에서는 제대로 동작하지 않습니다. 평균적으로 정확한 추정기라도 극단값에서는 틀립니다. 대부분의 댓글에는 필요 이상으로 큰 공간을 확보해 빈 여백이 생기고, 높이가 많이 필요한 댓글에는 공간이 부족해 내용이 잘리거나 내부에 별도의 스크롤바가 생깁니다. 렌더링 후 실제 높이를 측정해 공용 오프셋 테이블에 다시 반영하면, 사용자가 이미 스크롤하고 있는 동안 아래쪽의 모든 내용이 움직입니다. 이것이 스크롤 점프이고, 큰 PR에서는 점프 폭도 커집니다.
따라서 댓글에는 다른 계약이 필요합니다. 이런 콘텐츠에 대해 "페인트 전에 모든 높이를 알고 있다"는 것은 달성할 수 없습니다. 대신 우리가 보장할 수 있는 것은 다음과 같았습니다. 높이는 일정 범위 안에 있고, 필요할 때 지연 측정하며, 보정 폭은 작게 유지하고 사용자가 현재 보고 있는 대상을 기준으로 고정합니다.
하나가 아닌 두 개의 기하 구조
이 문제를 다룰 수 있게 해준 핵심 아이디어는 서로 다른 두 종류의 콘텐츠에 하나의 기하 구조를 억지로 적용하지 않는 것이었습니다. 문서의 높이를 독립적인 두 영역으로 나눴습니다.
total height = deterministic code height (exact, known up front)
+ Σ dynamic block effective heights (estimated, then measured)
+ scroll padding
코드 기하 구조(Code geometry) 는 기존 방식을 그대로 유지합니다. 결정론적이고, 누적 합(prefix sum)으로 계산되며, 정확합니다. 댓글 크기가 바뀌더라도 다시 계산하지 않습니다.
동적 블록 기하 구조(Dynamic block geometry) 는 리뷰 스레드, 초안, 답글 작성기처럼 높이를 예측할 수 없는 모든 요소를 담당합니다. 각 블록은 현재 위치가 아니라 "무엇인지"를 기준으로 식별합니다. 콘텐츠가 로딩되어도 유지되는 안정적인 키를 사용하고, 픽셀 좌표가 아니라 파일, 줄, 어느 쪽(side)에 속하는지를 기준으로 고정하기 때문에 리플로우(reflow)가 발생해도 대상을 잃어버리지 않습니다. 또한 블록 높이에 영향을 줄 수 있는 모든 요소의 핑거프린트(fingerprint)를 보관합니다. 콘텐츠 내용, <details>가 열려 있는지, 작성기가 활성화되어 있는지 등이 포함됩니다. 마지막으로 측정했을 때의 너비도 기록하는데, 너비는 구간 단위로 반올림합니다. 덕분에 평범한 창 크기 변경만으로 문서의 모든 측정값이 무효화되지 않습니다.
블록의 유효 높이를 결정하는 방식은 간단합니다. 유효한 실제 측정값이 있으면 그것을 사용하고, 핑거프린트와 너비가 여전히 일치하는 캐시 값이 있으면 캐시를 사용하며, 둘 다 없으면 추정값을 사용합니다. 이 높이들은 코드 행과 별도의 인덱스에 저장되므로 댓글 크기가 바뀌어도 코드 기하 구조를 다시 만들 필요가 없습니다. 또한 블록 개수는 코드 행 수가 아니라 댓글 수에 따라 결정됩니다. 첫 페인트에서 모든 블록을 한꺼번에 마운트하거나 측정하지만 않는다면 블록 수가 수천 개 정도인 것은 문제가 되지 않습니다.
측정 스케줄러, 그리고 처음에 했던 실수
이 부분을 제대로 만드는 데 가장 오랜 시간이 걸렸습니다. 처음 설계가 잘못됐기 때문인데, 그 과정에서 중요한 교훈을 얻었습니다.
동적 콘텐츠를 측정하는 가장 직관적인 방법은 블록마다 ResizeObserver를 하나씩 두는 것입니다. 요소를 감시하다 크기가 바뀌면 측정한 높이를 레이아웃에 다시 반영하는 방식입니다. 처음에는 우리도 이렇게 설계했지만, 성능을 다듬는 과정에서 폐기했습니다. 대규모 가상화 화면에서 피해야 하는 피드백 루프가 만들어졌기 때문입니다. 자신이 감시하고 있는 요소의 높이를 다시 레이아웃에 쓰는 옵저버는 자기 자신을 다시 실행시킬 수 있고, 비용은 마운트된 블록 수가 늘어날수록 함께 증가합니다.
최종적으로 적용한 것은 유휴 상태와 스크롤 상태를 고려하는 단일 측정 패스였습니다. 결정론적인 코드 영역과 마찬가지로 엄격한 규칙을 적용했습니다.
- 핫 패스(hot path) 밖에서 실행합니다. 화면에 보이는 범위가 안정됐을 때 실행하며, 스크롤 프레임마다 실행하지 않습니다. 스크롤이 진행 중이면 아예 기다립니다. 스크롤 중간에 리플로우가 발생하는 것이 바로 우리가 피하려는 끊김(jank)이기 때문입니다. 스크롤이 멈추면 다시 실행합니다.
- 뷰포트 주변으로 범위를 제한합니다. 뷰포트에서 약 2,400px 안에 있는 블록만 측정 후보가 됩니다. 따라서 작업량은 O(viewport)입니다. 멀리 있는 블록은 계속 추정값을 사용하다가 화면에 가까워질 때 보정됩니다.
- 화면에 있는 요소의 측정값을 우선합니다. 마운트된 블록은 화면에 존재하므로 실제 렌더링 높이가 정답입니다. 측정 패스는 마운트된 후보의 높이를 한 번에 읽습니다. 쓰기 작업을 중간에 끼우지 않기 때문에 리플로우도 한 번만 발생합니다. 마운트된 블록을 오래된 추정값 때문에 측정에서 제외하는 일은 절대 없습니다. 이 규칙 하나로 우리가 만난 가장 골치 아픈 버그를 해결했습니다. 댓글 아래에 빈 공간이 띠처럼 생기는 문제였는데, 이미 마운트된 블록이 측정 대상에서 빠져 실제보다 큰 추정 높이를 계속 유지한 것이 원인이었습니다.
- 화면 밖 측정은 제한된 대체 수단입니다. 아직 마운트되지 않았지만 화면 가까이에 있는 블록은 실제로 화면 안으로 들어오기 전에 공간을 보정할 수 있도록 최대 한 번 화면 밖에서 렌더링합니다. 다만 뷰포트보다 높은 블록은 이 과정조차 생략합니다. 이런 블록의 과도한 예약 공간은 폴드 아래쪽에 숨어 있으므로 굳이 렌더링 비용을 지불할 가치가 없습니다.
- 나머지는 옵저버가 감지합니다. 답글 작성기에 텍스트를 입력하거나, 이미지 로딩이 끝나거나,
<details>를 열고 닫는 것처럼 핑거프린트가 바뀌지 않으면서 스크롤과도 무관하게 높이가 바뀌는 경우가 있습니다. 각 마운트된 블록에는 여전히ResizeObserver가 있지만, 기본적으로 하는 일은 해당 블록을 표시해두고 유휴 측정 패스가 다시 읽도록 하는 것뿐입니다. 옵저버 자체가 높이를 쓰지는 않습니다. 그래야 앞서 피하려 했던 피드백 루프가 닫히지 않습니다. 언마운트되면 옵저버를 해제하고, 비활성 PR 탭에서는 아무것도 관찰하지 않습니다. - 의도적인 예외가 하나 있습니다. 사용자가 직접 발생시킨 크기 변경은 기다리면 눈에 띄게 어색했습니다.
<details>를 펼치거나, 답글 작성기를 열거나, 이미지가 나타나는 경우입니다. 블록은 즉시 커지는데 아래의 코드는 다음 유휴 측정 패스가 실행되어야 이동했습니다. 한 프레임 동안 댓글만 먼저 커지고 아래쪽 콘텐츠는 기존 위치에 남아 있어 두 단계로 움직이는 것이 보였습니다. 그래서 블록이 마운트되어 화면에 보이는 경우에는 옵저버가 같은 프레임에서 높이를 측정하고 페인트 전에 보정을 적용하도록 바꿨습니다. 블록이 커지면서 코드 위치도 함께 조정되고, 아래 내용 전체가 동시에 움직입니다. 이것이 앞에서 피하려 했던 루프로 되돌아가지 않도록 두 가지 안전장치를 뒀습니다. 먼저 프레임당 동기 커밋을 최대 한 번만 허용해 여러 크기 변경이 한 번으로 합쳐지도록 했습니다. 그리고 활성 스크롤 중에는 절대 동기 커밋하지 않고 배치 측정 패스로 넘깁니다.
스크롤 앵커링: 사용자의 움직임을 방해하지 않고 보정하기
실제로 측정한 높이가 추정값과 다르면 스크롤바 계산도 바뀌고, 단순하게 처리하면 뷰포트가 튀게 됩니다. 해결 방법은 픽셀 위치가 아니라 대상의 정체성을 기준으로 보정하는 것입니다.
- 높이 업데이트를 적용하기 전에 사용자가 현재 어느 대상에 고정되어 있는지 기록합니다. 행이나 블록 자체의 식별자와 그 안에서의 오프셋을 저장합니다.
- 높이 차이를 적용합니다.
- 같은 대상이 새 좌표계에서 어느 픽셀 위치에 있는지 다시 계산합니다.
- 해당 대상이 뷰포트 안에서 같은 위치에 머물도록 스크롤합니다.
여기에 자연스러운 동작을 위한 몇 가지 규칙을 더했습니다.
- 뷰포트 위쪽의 블록 높이가 바뀌면 → 변화량만큼 보정합니다. 현재 보고 있던 위치가 유지됩니다.
- 뷰포트 아래쪽의 콘텐츠가 하이드레이션(hydration)되면 → 보정하지 않습니다. 어차피 사용자에게 보이지 않습니다.
- 사용자가 화면에 보이는 블록에서
<details>를 펼치거나 답글을 열었다면 → 해당 블록에 대해서는 위쪽 블록 기준 보정을 억제합니다. 사용자의 조작에 직접 반응하는 느낌을 유지하고, 아래 콘텐츠가 자연스럽게 밀려 내려가도록 합니다. - 활성화된 포인터나 휠의 관성을 방해하지 않습니다. 보정은 해당 프레임 이후에 묶어서 처리합니다.
마지막 규칙에는 까다로운 함정이 있었고, 실제로 문제가 됐습니다. "사용자가 스크롤 중일 때는 보정하지 않는다"는 로직은 마지막으로 감지한 스크롤 시간을 기준으로 동작했는데, 프로그램이 발생시킨 스크롤도 이 타임스탬프를 갱신했습니다. 파일 트리 사이드바를 열고 닫으면 diff 영역의 너비가 바뀝니다. 줄바꿈이 켜져 있다면 현재 위치 위쪽의 모든 줄이 다시 배치되면서 시각적으로 차지하는 줄 수가 달라지고, 전체 좌표 공간이 변합니다. 이 과정에서 화면 자체가 위치를 정리하면서 작은 스크롤 이벤트를 발생시킵니다. 기존 가드는 이를 "사용자가 방금 스크롤했다"고 판단해 사용자의 위치를 유지하기 위한 보정을 오히려 건너뛰었습니다. 결국 읽고 있던 파일이 화면 밖으로 밀려났습니다. 해결책은 사용자가 발생시킨 스크롤과 화면 자체가 발생시킨 스크롤을 구분하는 것이었습니다. "사용자가 현재 상호작용 중인가?"를 판단하는 조건은 반드시 자기 자신의 부수 효과로 충족될 수 없게 만들어야 합니다.
이렇게 보정 폭은 작게 유지하고, 이미 가지고 있는 측정값을 재사용하며, 사용자가 현재 보고 있는 대상을 계속 따라갑니다.
Part 2: 화면 뒤에서 동작하는 파이프라인
diff 화면의 속도는 결국 데이터를 공급하는 파이프라인보다 빨라질 수 없습니다. 이 작업에서 데이터 쪽에 적용한 세 가지 원칙이 UI에서 할 수 있는 일에도 큰 영향을 줬습니다.
첫 번째는 콘텐츠보다 구조를 먼저 스트리밍하는 것입니다. diff는 점진적으로 요청하므로 문서 전체가 로딩 중이어도 파일 트리와 메타데이터를 먼저 그릴 수 있습니다. 또한 전체 리뷰 스레드 집합도 하나씩 늦게 도착하도록 두지 않고 처음부터 미리 확정합니다.
두 번째는 개별 항목에 필요한 작업을 실제로 필요해질 때까지 미루는 것입니다. 구문 강조(syntax highlighting)는 메인 스레드 밖에서 처리합니다. 따라서 행은 먼저 일반 텍스트로 나타나고 결과가 도착하면 색상이 적용됩니다. 구문 강조는 스크롤을 막는 기능이 아니라 화면을 나중에 개선하는 기능이 됩니다. 큰 마크다운 본문과 제안된 변경사항의 주변 컨텍스트도 마찬가지입니다. 뷰포트에 가까워지기 전까지는 아무것도 만들지 않습니다.
세 번째 원칙은 어떤 비용을 감수할 가치가 있는지에 관한 것입니다. 다른 화면으로 이동할 때 diff 문서를 해제하는 것은 올바른 기본 정책입니다. 문서 자체가 크기 때문에 방문했던 문서를 전부 계속 들고 있으면 긴 세션 동안 메모리를 계속 잡아먹게 됩니다. 하지만 PR 메타데이터는 유지됩니다. 그래서 다시 돌아왔을 때 diff 주변의 셸, 헤더, 파일 트리는 즉시 그려지지만, 방금 전까지만 해도 완전히 로딩되어 있던 diff는 다시 몇 초 동안 기다려야 합니다. 빈 diff를 감싸고 있는 셸만 즉시 나타나면, 실제 대기 시간이 더 짧더라도 화면이 고장 난 것처럼 느껴집니다.
그래서 기존 정책은 유지하되 캐시(cache)를 추가했습니다. 최근 몇 개의 diff는 메모리에 유지하고, 그 이상은 제거하며, 백그라운드 새로고침이 캐시가 오래됐는지 판단하도록 했습니다.
Part 3: 측정 루프, 또는 우리가 실제로 버그를 찾아낸 방법
이 프로젝트에서 거의 모든 버그는 어느 순간 나타나기 전까지는 보이지 않았고, 손으로 재현하기도 매우 괴로웠습니다. 전형적인 버그 리포트는 이런 식입니다. "일부 댓글 아래에 빈 공간이 생기는데 가끔만 나타나고, 큰 PR에서만 발생하며, 아래로 스크롤했다가 다시 올라오면 사라집니다."
화면만 쳐다봐서는 이런 문제를 디버깅할 수 없습니다. 그래서 기계적으로 문제를 디버깅할 수 있는 도구를 만들었습니다.
임시 로그가 아니라 앱의 실제 신호를 계측하기
가장 단순한 워크플로는 여기저기에 console.log를 넣고, 직접 동작을 실행해 본 뒤, 출력을 복사해 분석할 사람이나 시스템에 붙여넣고, 로그를 삭제한 뒤 다시 반복하는 것입니다. 느리고, 사람이 계속 개입해야 하며, 더 큰 문제는 실제 앱의 동작이 아니라 직접 임시로 만든 계측 코드를 측정하게 된다는 점입니다.
그래서 화면 자체가 자신의 불변 조건을 확인할 수 있도록 영구적인 구조화 프로브(probe)를 넣었습니다. 매 렌더링마다 다음과 같은 단순한 질문에 스스로 답합니다.
- 화면에 존재하는 요소가 실제로 뷰포트 범위에 제한되어 있는가? 지금 마운트된 행과 댓글 블록은 몇 개인가?
- 측정 작업이 프레임당 한 번의 커밋으로 제대로 합쳐지고 있는가? 해당 프레임은 얼마나 오래 걸리는가?
- 현재 적용 중인 스크롤 보정의 크기는 어느 정도인가?
- 스크롤이 시작된 이후에 댓글 블록이 새로 삽입된 적이 있는가? 백엔드에서 전체 구조가 도착한 뒤라면 반드시 0이어야 합니다.
- 블록별 옵저버가 언마운트될 때 실제로 정리되는가? 아니면 블록마다 하나씩 누수되고 있는가?
이 값들이 객관적인 통과/실패 기준입니다. 그리고 댓글이 매우 많은 거대한 가상 PR 픽스처(fixture)를 대상으로 실행하는 엔드투엔드 테스트에 예산(budget) 형태로 검증 조건을 넣었습니다. 이제 CI가 화면이 정상적인 상태인지 알려줄 수 있습니다.
루프를 자동 운전으로 돌리기
핵심은 자동으로 돌아가는 변경 → 측정 → 개선 루프였습니다. 두 가지 경로로 실행했습니다.
헤드리스 프로브 경로(headless probe lane) 는 목 서버를 대상으로 선언형 흐름을 실행했습니다. 예를 들어 PR을 열고, 특정 비율까지 스크롤하고, details 블록을 열고 닫고, 창 크기를 변경합니다. 그 과정에서 앱에 이미 들어 있는 프로덕션 계측 데이터를 읽습니다. React 렌더링 횟수, 성능 타임라인, 끊김을 측정하는 requestAnimationFrame 샘플러 등이 포함됩니다. 계측, 실행, 수집, 분석, 우선순위 지정까지 전체 과정을 스스로 수행하고 병목을 순서대로 출력했습니다. 실행 흐름 자체는 런타임에 프로브로 전달하는 JSON일 뿐이므로, 에이전트는 소스 코드 한 줄을 수정하지 않고도 자연어로 흐름을 설명하는 것만으로 원하는 동작을 프로파일링할 수 있었습니다.
오토파일럿(autopilot) 은 실제 데스크톱 앱에서 거대한 PR 시나리오를 사람 없이 반복 실행했습니다. 먼저 댓글이 아직 스켈레톤 상태인 콜드 상태로 실행하고, 다음에는 댓글이 로딩된 웜 상태로 실행했습니다. <details> 블록을 열고 닫고, 답글 작성기를 열었다 취소하고, 파일을 접었다 펼치고, 사이드바 트리를 열고 닫고, 파일 목록 깊숙한 곳까지 훑고, 창 크기도 변경했습니다.
모든 측정값은 앱의 디스크 로그에도 그대로 기록됐습니다. 덕분에 키보드 앞에 사람이 없어도 에이전트가 런타임 동작을 읽을 수 있었습니다. 각 샘플에는 상태 신호(health signal)가 포함됐고, 이것이 객관적인 검증 기준이 됐습니다. 웜 상태의 샘플이 정상이라고 판단되려면 전체 스크롤 범위에서 댓글 사이에 채워지지 않은 빈 공간이 없어야 하고, 비어 있는 댓글 블록이 없어야 하며, 실제 스레드 콘텐츠가 제대로 마운트되어 있어야 했습니다. 파일 목록 깊은 곳까지 훑는 구간도 모두 포함했습니다.
우리가 사용한 반복 과정은 다음과 같습니다.
- 실제 엔진에서 사람 없이 재현합니다. 오토파일럿을 실행하고 반복시킨 뒤 디스크 로그를 읽습니다.
- 눈으로 보지 말고 상태 신호로 탐지합니다. 샘플 필드를 신뢰합니다.
- 의심되는 경계를 좁혀서 측정합니다. 신호가 비정상적이면 해당 위치에 구조화 프로브 하나만 좁게 추가하고 다시 실행해 결과를 읽습니다. 화면 코드를 수정하면 실행 중인 창에 핫 리로드되고 오토파일럿도 다시 준비되기 때문에, 바로 다음 반복 주기에서 새 측정값을 얻을 수 있습니다.
- 임시 장치를 제거합니다. 불변 조건을 이해한 뒤에는 테스트와 설계 문서에 이를 고정하고, 실제 탐지에 필요한 신호만 남깁니다.
여기까지 구현한 결과
이 정도로 큰 PR을 리뷰하려면 예전에는 둘 중 하나를 선택해야 했습니다. 기다리거나, 포기하고 다른 곳에서 읽는 것이었습니다.
리뷰는 크기가 정해진 문서가 아닙니다. 읽고 있는 동안 계속 형태가 달라지는 대화입니다. 따라서 그 아래에 있는 화면 구조도 나중에 임시로 덧붙이는 것이 아니라 처음부터 이런 특성을 고려해 만들어야 합니다.
그 결과 이제 100만 줄짜리 diff와 수백 개의 스레드 댓글이 있어도 일반적인 크기의 PR처럼 열리고, 스크롤되고, 동작하는 PR 화면을 만들었습니다. 댓글은 작은 스크롤 박스 안에서 잘리지 않고 전체가 렌더링됩니다. 접힌 섹션을 펼치면 그 아래의 코드만 이동하고 다른 부분은 움직이지 않습니다. 방금 나왔던 PR로 돌아가면 이전에 보고 있던 위치로 복귀합니다.
코드 리뷰가 일이라면 이미 고통스럽다고 알고 있는 PR에서 직접 차이를 느껴볼 만합니다. 가장 심한 것을 하나 열어보세요.