안녕하세요! 코드브릭스(Code-Bricks)를 열심히 빚어내고 있는 프론트엔드 개발자입니다.
지난 포스팅에서 텍스트 차이점 분석기(Diff Checker)의 화려한 Side-by-Side UI와 스크롤 동기화 로직을 구현하며 "이제 다 만들었다!" 하고 쾌재를 불렀던 것, 기억하시나요? (※ 아, 참고로 이 모든 눈물겨운 사투 끝에 완성될 차이점 분석기의 실제 사이트 링크는 조만간 본문 하단에 멋지게 연결해 둘 예정이니 꼭 다시 들러주세요!)
작은 텍스트나 몇백 줄짜리 코드를 넣었을 때는 정말 깃허브(GitHub) 뺨치게 부드럽고 예쁘게 렌더링이 되었습니다. 하지만 제 도구의 FAQ에도 적어두었듯, "과연 1만 줄이 넘어가는 거대한 서버 로그 파일이나 수백 페이지짜리 영문 소설을 통째로 넣으면 어떻게 될까?"라는 개발자 특유의 호기심이 발동했습니다.
결과는 어땠을까요? 텍스트를 붙여넣고 '비교하기' 버튼을 누르는 순간, 브라우저가 하얗게 질리더니 마우스 커서가 뺑글뺑글 돌기 시작했습니다. 그리고 곧이어 크롬 브라우저의 악명 높은 경고창, "페이지가 응답하지 않습니다(Page Unresponsive)"가 뜨면서 탭이 완전히 뻗어버렸습니다.
오늘은 이 절망적인 상황을 극복하기 위해, 프론트엔드 개발자로서 메인 스레드의 한계와 렌더링 지연(Freezing) 현상을 어떻게 해결했는지, 그 치열했던 [트러블슈팅 1편]을 들려드리겠습니다.
1. 브라우저는 왜 뻗어버렸을까? (싱글 스레드의 비애)
이 끔찍한 프리징(Freezing) 현상의 원인을 파악하려면 자바스크립트(JavaScript)의 태생적인 특징을 이해해야 합니다. 자바스크립트는 기본적으로 '싱글 스레드(Single-Thread)' 언어입니다. 쉽게 말해, 브라우저 안에서 일하는 일꾼이 딱 '한 명'뿐이라는 뜻입니다.
이 불쌍한 일꾼(메인 스레드)은 혼자서 화면에 버튼도 그려야 하고, 사용자의 마우스 클릭 이벤트도 들어야 하고, 스크롤도 부드럽게 내려줘야 합니다. 그런데 제가 버튼을 누르는 순간, 이 일꾼에게 어마어마한 일감을 던져준 것입니다. 바로 1편에서 설명했던 시간 복잡도 의 'LCS(최장 공통 부분 수열) 알고리즘' 연산입니다.
만약 왼쪽 창에 1만 줄, 오른쪽 창에 1만 줄의 텍스트가 들어왔다고 생각해 보세요. (1억) 번의 연산 루프가 돌아가야 합니다. 메인 스레드가 이 1억 번의 계산을 하느라 진땀을 빼는 동안, 브라우저는 화면을 업데이트하거나 "로딩 중입니다"라는 빙글빙글 도는 스피너 애니메이션조차 그릴 여력이 없어집니다. 말 그대로 화면이 '일시 정지' 상태가 되어버리는 것이죠.
이대로 출시한다면 대용량 문서를 검토해야 하는 법무팀이나 데이터 분석가들에게는 최악의 UX(사용자 경험)를 제공하게 될 것이 뻔했습니다.

2. 해결책 1단계: 일꾼을 추가로 고용하자! 'Web Worker' 도입
첫 번째 병목 현상은 바로 '무거운 연산' 그 자체였습니다. 계산을 안 할 수는 없으니, 저는 메인 스레드 대신 뒷단에서 조용히 계산만 전담할 새로운 일꾼을 고용하기로 했습니다. 바로 HTML5의 강력한 기능인 '웹 워커(Web Worker)'입니다.
Web Worker를 사용하면 메인 스레드와 완전히 분리된 백그라운드 스레드에서 자바스크립트 코드를 실행할 수 있습니다.
- 메인 스레드의 역할: 사용자에게 "잠시만 기다려주세요"라는 예쁜 로딩 스피너를 보여주고, 입력받은 1만 줄의 텍스트를 Web Worker에게 택배로 보냅니다(postMessage).
- Web Worker의 역할: 메인 스레드가 건네준 텍스트를 받아, 백그라운드에서 1억 번의 LCS 알고리즘 연산을 미친 듯이 수행합니다. 브라우저 화면과는 독립되어 있으므로 화면이 멈추지 않습니다. 계산이 끝나면 결과 데이터(배열)만 다시 메인 스레드로 툭 던져줍니다.
Copy// [수정 전] 브라우저가 멈추던 동기식 로직 (지옥의 시작)
function handleCompareClick() {
showLoading(true); // 하지만 동기적 연산 때문에 화면에 안 그려짐!
const diffResult = runHeavyLCSAlgorithm(oldText, newText); // 화면 프리징 발생
renderDiff(diffResult);
}
// [수정 후] Web Worker를 활용한 비동기 로직 (평화 도래)
function handleCompareClick() {
showLoading(true); // 이제 스피너가 예쁘게 잘 돌아갑니다.
// 워커 생성 및 데이터 전송
const worker = new Worker('diffWorker.js');
worker.postMessage({ oldText: oldText, newText: newText });
// 워커가 계산을 끝내고 결과를 돌려줄 때까지 기다림
worker.onmessage = function(event) {
const diffResult = event.data;
renderDiff(diffResult);
showLoading(false);
worker.terminate(); // 수고한 일꾼 퇴근
};
}
Web Worker를 도입하자, 1만 줄의 연산이 돌아가는 동안에도 로딩 스피너가 빙글빙글 부드럽게 돌아가고 브라우저 탭이 뻗지 않는 기적을 맛보았습니다!
3. 해결책 2단계: DOM의 반란, '가상 스크롤(Virtual Scroll)' 기법
"휴, 드디어 살았다!" 하고 한숨을 돌린 것도 잠시. 워커가 결과물을 던져주고 나서 화면에 그 결과물을 렌더링하는 순간, 브라우저가 다시 한번 '버벅(Lag)'거리기 시작했습니다. 대체 왜 또 이러는 걸까요?
원인은 과도한 DOM(Document Object Model) 렌더링에 있었습니다. LCS 연산 결과, 왼쪽 창에 1만 개, 오른쪽 창에 1만 개, 도합 2만 개의 <div> 태그와 색상 하이라이팅 CSS를 브라우저 화면에 한 번에 그려 넣으려다 보니, 브라우저의 렌더링 엔진(Painting, Layout)이 비명을 지른 것입니다.
아무리 계산을 백그라운드에서 끝냈어도, 웹 페이지에 2만 개의 블록을 한 번에 찍어내는 건 프론트엔드 성능 최적화의 1급 금기사항입니다. 여기서 제 머릿속을 스쳐 지나간 구원투수가 바로 '가상 스크롤(Virtual Scroll, 또는 Windowing)' 기법입니다.
사용자의 모니터 화면에 한 번에 보이는 줄 수는 기껏해야 30줄에서 50줄 남짓입니다. 그렇다면 굳이 보이지도 않는 9,950줄을 미리 다 그려둘 필요가 있을까요? 가상 스크롤은 전체 데이터가 1만 개가 있더라도, 현재 사용자의 눈에 보이는(스크롤 위치에 해당하는) 딱 50개의 <div>만 화면에 그리고, 사용자가 스크롤을 내리면 껍데기(DOM)는 그대로 둔 채 안의 텍스트 데이터만 빠르게 교체(Swap)해 주는 기법입니다.

이 기법을 직접 바닥부터 짜려면 스크롤 높이와 엘리먼트 높이를 일일이 계산해야 하므로 무척 고된 작업입니다. 저는 프로젝트의 빠른 완성을 위해 React 생태계에서 널리 쓰이는 react-window (또는 react-virtualized) 같은 윈도잉 라이브러리의 개념을 적극 차용하여 로직을 재설계했습니다.
결과는 대성공이었습니다! DOM 노드의 개수가 2만 개에서 단 100개(좌우 합쳐서)로 극단적으로 줄어들자, 아무리 긴 텍스트를 스크롤해도 60fps(초당 60프레임)의 부드러움을 잃지 않았습니다.
4. 멘토의 마무리 조언 및 다음 편 예고
텍스트 차이점 분석기(Diff Checker)를 단순한 토이 프로젝트로 여길 수도 있지만, 그 안에는 프론트엔드 개발자가 마주할 수 있는 가장 본질적인 두 가지 성능 이슈가 모두 숨어있었습니다.
- CPU 바운드 병목 현상 (해결: Web Worker)
- 렌더링 바운드 병목 현상 (해결: Virtual Scroll)
이 두 가지 강력한 무기를 장착하고 나니, 이제 수천, 수만 줄의 계약서나 로그 파일도 두렵지 않은 강력한 툴로 진화하게 되었습니다.
하지만 산 넘어 산이라고 했던가요? 성능을 잡고 났더니 이번엔 텍스트 데이터 자체의 결함들이 저를 괴롭히기 시작했습니다. 눈에 보이지 않는 공백 문자 하나 때문에 전체 문장이 달라졌다고 인식하는 억울한 상황들 말이죠.
다음 포스팅인 [트러블슈팅 2편: 공백 하나 때문에 코드 전체가 바뀌었다고? 엣지 케이스 정복기]에서는 정규 표현식을 활용한 텍스트 전처리(Sanitization)의 눈물겨운 사투를 들려드리겠습니다. 코드브릭스의 실제 서비스 링크도 함께 가져올 테니, 잊지 말고 꼭 다시 방문해 주세요! 오늘도 행복한 코딩 하세요!