대용량 파일 열기가 왜 28초나 걸렸나 — 그리고 2초로 줄인 방법
2026년 8월 6일 · performance · profiling · architecture
에디터에서 파일을 연다는 것은 단순히 디스크에서 바이트를 읽는 것만을 뜻하지 않습니다. 첫 번째 줄이 화면에 나타나기 전까지 에디터는 읽어 온 바이트를 줄 단위로 나누고, 구문에 색을 입히는 데 필요한 정보를 찾아내고, 각 줄을 화면에 표시할 수 있도록 배치해야 합니다.
에디터를 만들다 보면 자연스럽게 거치게 되는 과정이지만, 각 단계에는 대용량 파일에서만 눈에 띄는 성능 문제가 하나씩 숨어 있습니다.
Crimson Editor는 과거에 100MB, 약 900,000줄짜리 파일을 여는 데 28초, 저장하는 데 13초가 걸렸습니다. 각 단계의 병목을 하나씩 찾아 개선한 결과, 지금은 같은 파일을 여는 데 2.2초, 저장하는 데 0.14초가 걸립니다.
어떤 작업에서 시간이 오래 걸렸는지, 처음 구현은 왜 느렸는지, 그리고 이를 어떻게 개선했는지 차례대로 살펴보겠습니다.
파일을 열 때 에디터가 하는 세 가지 일
파일을 여는 과정은 크게 세 단계로 나눌 수 있습니다.
- 읽기 — 디스크에서 바이트를 읽고 디코딩한 뒤, 줄 단위로 나눕니다.
- 토큰화 — 구문 강조에 필요한 키워드, 문자열, 주석, 숫자 등을 각 줄에서 찾습니다.
- 화면 배치 — 단어와 문자의 너비를 계산해 각 줄을 화면 행에 배치합니다.
100MB 파일을 기준으로 개선 전후 시간을 측정하면 다음과 같습니다.
| 단계 | 개선 전 | 개선 후 | 향상 |
|---|---|---|---|
| 1. 읽어서 줄로 나누기 | 3,673 ms | 752 ms | 4.9× |
| 2. 강조용 토큰화 | 6,149 ms | 557 ms | 11× |
| 3. 화면 배치 | 17,417 ms | 49 ms | 355× |
| 파일 열기 전체 시간 | 28.4 s | 2.2 s | 13× |
세 단계의 문제는 서로 달라 보이지만, 그 밑에는 공통된 원인이 있었습니다.
한 번에 필요한 데이터는 일부뿐인데도, 매번 파일 전체 크기에 비례하는 작업을 하고 있었다는 점입니다. 결국 각 단계의 최적화는 불필요하게 전체를 처리하지 않도록 만드는 과정이었습니다.
1단계 — 바이트를 읽어 줄로 나누기
첫 번째 단계는 파일에서 바이트를 읽고 줄 단위로 나누는 작업입니다.
가장 단순한 구현은 작은 버퍼를 읽은 뒤 첫 번째 줄바꿈 문자를 찾아 한 줄을 반환하고, 다음 줄부터 같은 작업을 반복하는 방식입니다. Crimson Editor도 오랫동안 이 방식으로 동작했습니다. 버퍼 크기는 512바이트였습니다.
문제는 다음 줄부터 다시 읽기 위해 파일 위치를 줄 끝으로 되돌렸다는 점입니다. 900,000줄짜리 파일이라면 읽기 작업도 약 90만 번, 파일 위치를 되감는 작업도 약 90만 번 발생합니다. 그 결과 상당수의 바이트를 여러 차례 다시 읽게 됩니다.
저장도 비슷했습니다. 줄 하나를 저장할 때마다 쓰기 작업을 한 번씩 수행했기 때문에 약 90만 번의 시스템 호출이 발생했습니다.
해결 방법은 줄이 아니라 블록 단위로 처리하는 것이었습니다.
파일을 64KiB씩 읽고, 블록 안에 포함된 줄은 한 번에 모두 처리합니다. 블록 경계에 걸쳐 있어 아직 완성되지 않은 줄만 다음 블록으로 넘깁니다. 저장할 때도 데이터를 64KiB까지 모은 뒤 버퍼가 가득 차면 한 번에 기록합니다.
이렇게 바꾸자 읽기 속도는 4.9배, 저장 속도는 96배 빨라졌습니다.
2단계 — 각 줄을 토큰화하기
두 번째 단계는 구문 강조에 필요한 정보를 찾는 작업입니다. 각 줄에서 키워드, 문자열, 주석, 숫자 등이 어디에 있는지 분석해야 합니다.
이를 위해 문자마다 작은 상태 기계를 실행합니다. 900,000줄 전체를 처리해야 하니 처음에는 이 문자 스캔이 가장 큰 병목일 것이라고 예상했습니다.
하지만 프로파일링 결과는 달랐습니다.
Crimson Editor는 토큰화가 진행되는 동안 20줄마다 진행 표시줄을 갱신하고 있었습니다. 900,000줄이라면 진행 표시줄을 약 45,000번 다시 그리는 셈입니다.
한 번 갱신할 때마다 오프스크린 비트맵을 만들고, 테두리와 진행률을 그린 뒤 화면으로 복사하고, 사용한 객체를 다시 해제했습니다. 실제로 화면에 표시되는 진행률은 0%부터 100%까지 101가지뿐인데, 같은 모습을 수백 번씩 반복해서 그린 것입니다.
결국 6초의 대부분은 토큰을 찾는 데 사용된 것이 아니라 진행 표시줄을 그리는 데 사용되고 있었습니다.
여기서 얻은 교훈은 단순합니다.
사용자가 구분할 수 없을 만큼 자주 화면을 다시 그리는 것은 의미가 없습니다.
해결 방법도 간단했습니다. 진행 표시줄 내부에서 이전과 현재 퍼센트를 비교하고, 값이 바뀌지 않았다면 그리지 않도록 했습니다. 이 검사를 진행 표시줄 자체에 넣었기 때문에 이를 호출하는 모든 코드에 한 번에 적용할 수 있었습니다.
문자 스캔도 함께 개선했습니다. 문자가 글자인지, 숫자인지, 구분자인지를 확인할 때마다 함수를 호출하는 대신, 언어별로 한 번 만들어 둔 조회 테이블에서 값을 읽도록 변경했습니다.
이 최적화만으로도 약 12%의 성능 개선이 있었지만, 진행 표시줄의 불필요한 갱신을 제거한 효과에 비하면 작은 편이었습니다.
3단계 — 줄을 화면에 배치하기
세 번째 단계는 각 줄을 실제 화면에 배치하는 작업입니다.
에디터는 단어나 문자의 픽셀 너비를 운영체제에 물어보고, 그 결과를 바탕으로 화면 행에 텍스트를 배치합니다.
기존 Crimson Editor의 가장 큰 문제는 이 작업을 파일 전체에 대해 수행했다는 점입니다. 900,000줄을 모두 배치했지만, 사용자가 한 번에 볼 수 있는 줄은 약 50줄뿐입니다.
나머지 899,950줄의 배치 결과는 계산하자마자 사용되지 않은 채 버려지고 있었습니다.
이를 해결하기 위해 줄을 실제로 화면에 표시할 때만 배치하도록 변경했습니다.
파일을 열 때는 화면 행 목록을 비어 있는 자리표시자로 채운 뒤 곧바로 반환합니다. 이후 각 행이 처음으로 화면에 그려질 때 필요한 배치를 수행합니다.
이 변경 하나만으로 화면 배치 시간은 17.4초에서 49밀리초로 줄었습니다. 약 355배의 개선입니다.
다만 지연 배치를 구현할 때 한 가지 해결해야 할 문제가 있었습니다. 바로 여러 줄에 걸쳐 이어지는 구문 상태입니다.
예를 들어 5,000번째 줄이 블록 주석(/* … */) 안에 있는지는 1번째 줄부터 4,999번째 줄까지의 내용에 따라 달라집니다. 그런데 지연 배치를 적용하면 앞부분의 줄은 아직 화면에 표시되지 않았으므로 배치되지 않았을 수 있습니다.
다행히 이 상태를 판단하는 데는 문자나 단어의 픽셀 너비가 필요하지 않습니다. 블록 주석 여부와 같은 정보는 이미 2단계에서 생성한 토큰에 포함되어 있습니다.
그래서 별도의 가벼운 패스를 추가했습니다. 이 패스는 각 행이 주석 안에 있는지 아닌지만 플래그로 기록합니다. 문자 너비를 측정하거나 화면 배치를 하지는 않습니다.
개선 후에도 남아 있는 49밀리초는 대부분 이 작업에 사용됩니다.
워드랩은 예외였습니다
지연 배치는 워드랩이 꺼져 있을 때 특히 효과적입니다. 워드랩이 없다면 논리적인 한 줄이 화면에서도 한 행이므로, 실제 배치를 하지 않아도 스크롤바에 필요한 전체 행 수를 바로 알 수 있습니다.
하지만 워드랩을 켜면 상황이 달라집니다.
한 줄이 화면 너비에 따라 여러 행으로 접힐 수 있기 때문에 실제로 배치해 보기 전에는 전체 화면 행 수를 알 수 없습니다. 이 때문에 워드랩을 켠 상태에서는 화면 배치에 여전히 12초가 걸렸습니다.
가장 큰 비용은 텍스트 너비 측정이었습니다.
접힌 한글 단어의 크기를 계산하기 위해 운영체제 함수를 약 360만 번 호출했습니다. 호출할 때마다 대체 글꼴을 찾고 문자를 셰이핑하는 과정이 반복됐습니다.
하지만 소스 파일에서 사용되는 서로 다른 문자는 보통 몇천 개에 불과합니다. 또한 Crimson Editor의 해당 그리기 경로에서는 문자 너비가 이웃 문자에 따라 달라지지 않았기 때문에, 단어 너비를 각 문자 너비의 합으로 계산할 수 있었습니다.
그래서 각 문자의 너비를 한 번만 측정하고 캐시에 저장했습니다. 같은 문자가 다시 나오면 운영체제에 다시 묻지 않고 캐시된 값을 사용했습니다.
그 결과 운영체제 호출 횟수는 약 360만 번에서 사실상 문자 종류 수만큼으로 줄었고, 워드랩 배치 시간은 12.3초에서 662밀리초로 감소했습니다.
다만 이 방식은 Crimson Editor의 현재 텍스트 렌더링 경로가 커닝이나 문자 간 셰이핑을 사용하지 않기 때문에 가능합니다.
커닝(kerning)은 AV처럼 특정 문자쌍의 간격을 보기 좋게 좁혀 붙이는 기능이고, 셰이핑(shaping)은 아랍 문자나 fi 합자처럼 이웃한 문자가 하나로 합쳐지며 모양과 너비가 달라지는 것을 말합니다. 둘 다 한 문자의 너비를 옆에 오는 문자에 따라 바꿉니다. 그러면 “문자 너비를 더한 값 = 단어 너비”라는 등식이 깨지므로, 문자 단위로 캐시한 값을 더해서는 올바른 단어 너비를 얻을 수 없습니다.
Crimson Editor는 이런 기능 없이 문자를 서로 간섭 없이 하나씩 나란히 그리기 때문에, 문자 너비의 합이 곧 단어 너비가 됩니다. 그래서 문자 단위 캐시가 정확히 들어맞습니다.
더 정교한 텍스트 엔진을 사용한다면 문자 하나의 너비가 주변 문자에 따라 달라질 수 있으므로, 캐시도 문자 단위가 아닌 셰이핑 결과 단위로 설계해야 합니다.
기억할 한 가지
에디터를 직접 만들고 있다면 이 글에서 한 가지만 기억해도 좋습니다.
파일 전체 크기가 아니라, 사용자가 지금 실제로 볼 수 있는 범위에 비례해 작업해야 합니다.
읽기와 저장은 한 줄씩 처리하지 말고 블록 단위로 흘려보내고, 화면 배치는 눈앞에 보이는 행부터 처리해야 합니다. 진행 표시줄처럼 사람이 변화를 알아차릴 수 없는 UI 갱신도 반복하지 않는 편이 좋습니다.
이 세 가지를 바꾸는 것만으로 Crimson Editor의 대용량 파일 열기 시간은 28초에서 2초로, 저장 시간은 13초에서 0.14초로 줄었습니다.