진행률 표시줄은 왜 99%에서 멈출까
목차10

설치 프로그램의 진행률 표시줄이 99%에서 멈춰 있는 걸 본 적이 있을 것이다. 98%까지는 시원하게 차오르던 막대가 마지막 1% 앞에서 갑자기 얼어붙는다. 마우스를 흔들어보고, 작업 관리자를 열어보고, 결국 "그냥 기다려볼까"와 "강제로 끌까" 사이에서 고민하게 된다.
이 장면이 너무 흔해서 우리는 보통 그러려니 하고 넘어간다. 그런데 개발자의 입장에서 다시 보면 꽤 이상한 장면이다. 99%까지 일을 잘하던 프로그램이 왜 하필 마지막에 와서 느려지는 걸까? 마지막 1%에 정말로 남은 일의 1%만 남아 있기는 한 걸까?
그래서 이번 포스팅에서는 진행률 표시줄이 왜 99%에서 멈추는지를 한번 뜯어보려고 한다. 진행률이라는 숫자가 정확히 무엇을 재는지, 마지막 1%에는 어떤 일이 숨어 있는지, 사람들은 그 막대를 어떻게 느끼는지, 그리고 이 이야기가 요즘의 AI 응답 화면과는 어떻게 이어지는지를 차례대로 이야기해보자.
진행률은 측정이 아니라 예측이다
먼저 진행률이라는 숫자의 정체부터 짚어보자. 우리가 화면에서 보는 퍼센트는 대략 이렇게 계산된다.
여기서 는 시간 까지 실제로 끝낸 작업량이고, 는 전체 작업량의 추정치다. 위에 붙은 모자 기호는 "이건 진짜 값이 아니라 추정한 값"이라는 뜻이다. 분자는 이미 끝난 일이니 셀 수 있지만, 분모는 일이 다 끝나기 전까지 아무도 정확히 모른다.
등산로의 표지판을 떠올려보면 감이 온다. "정상까지 1.2km"라는 표지판은 거리를 정확히 알려주지만, 남은 1.2km가 평지인지 깎아지른 바위길인지는 알려주지 않는다. 그래서 같은 1.2km라도 마지막 구간이 가장 오래 걸리는 일이 흔하다. 진행률 표시줄의 퍼센트도 거리 표지판처럼 남은 양은 알려주지만, 남은 양의 난이도까지는 알려주지 못한다.
즉, 진행률은 측정값이라기보다 예측값에 가깝다. 분자를 정직하게 세더라도, 분모가 틀리면 숫자는 틀린다. 그리고 작업 도중에 분모 가 처음 추정보다 커지면, 분자가 계속 늘어도 퍼센트는 제자리에 머물거나 오히려 느려진다. 99%에서 멈춘 막대는 대개 이 순간을 보여주는 것이다.
남은 시간 표시는 여기에 추정을 한 번 더 얹는다.
분모의 는 지금까지의 평균 속도이고, 분자는 남은 작업량의 추정치다. 즉, "지금까지의 속도가 끝까지 유지된다"는 가정 위에서 남은 양을 나누는 것이다. 문제는 작업 속도가 구간마다 다르다는 데 있다. 파일 복사 창의 남은 시간이 "2분"에서 "37분"으로, 다시 "5분"으로 널뛰는 이유가 바로 이 가정 때문이다.
// 지금까지의 평균 속도로 남은 시간을 추정
function estimateRemaining(doneWork: number, estimatedTotal: number, elapsedSec: number) {
const rate = doneWork / elapsedSec;
return (estimatedTotal - doneWork) / rate;
}
estimateRemaining(900, 1000, 90); // 10초, 작은 파일들을 빠르게 복사하던 중
estimateRemaining(900, 1000, 300); // 약 33초, 큰 파일 하나에서 속도가 떨어진 뒤그래서 운영체제나 다운로드 관리자들은 보통 평균 속도를 그대로 쓰지 않고, 최근 몇 초의 속도에 더 큰 무게를 두는 이동 평균으로 남은 시간을 계산한다. 그래도 근본적인 문제는 사라지지 않는다. 앞으로 남은 구간의 속도는 결국 아무도 모르기 때문에, 어떤 평균을 쓰든 과거의 속도로 미래를 짐작하는 일이라는 점은 똑같다.
공식 자체는 나눗셈 두 번이 전부다. 그런데 그 나눗셈에 들어가는 값 두 개가 모두 추정치라서, 결과는 우리가 기대하는 것보다 훨씬 자주 틀린다.
마지막 1%에 숨어 있는 일들
그렇다면 왜 하필 마지막 구간에서 추정이 크게 틀릴까? 이유는 마지막 구간에 미리 양을 세기 어려운 일들이 몰리기 때문이다.
| 작업 | 마지막 구간의 일 | 늦어지는 이유 |
|---|---|---|
| 파일 복사 | 쓰기 버퍼를 디스크에 기록 | 메모리에 쌓아둔 쓰기를 한 번에 처리 |
| 파일 업로드 | 서버의 처리와 응답 대기 | 브라우저의 업로드 진행률은 보낸 바이트만 셈 |
| 프로그램 설치 | 등록, 색인, 임시 파일 정리 | 작업량이 설치 환경마다 다름 |
| 프론트엔드 빌드 | 압축, 최적화, 소스맵 생성 | 앞 단계 결과물의 크기에 따라 시간이 달라짐 |
이 중에서 프론트엔드 개발자에게 가장 익숙한 건 아마 파일 업로드일 것이다. 브라우저의 xhr.upload.onprogress 이벤트가 알려주는 건 "요청 본문을 몇 바이트 보냈는가"다. 그래서 전송이 끝나면 진행률은 100%가 되지만, 서버가 그 파일을 저장하고, 썸네일을 만들고, 바이러스 검사를 하는 시간은 그 숫자에 들어 있지 않다.
파일 복사도 비슷하다. 운영체제는 쓰기 작업을 곧바로 디스크에 기록하지 않고 일단 메모리에 모아두었다가 한꺼번에 내려보내는 경우가 많다. 그래서 복사 창은 메모리에 옮겨 담는 속도를 보고 빠르게 차오르다가, 마지막에 그동안 미뤄둔 실제 디스크 기록을 기다리느라 멈춘 것처럼 보인다. 개발자에게 익숙한 말로 바꾸면, 비동기로 쌓아둔 작업을 마지막에 한 번에 await 하는 장면과 비슷하다.
그래서 많은 업로드 화면이 전송률을 그대로 보여주지 않고, 전송이 끝나면 "처리 중" 같은 별도의 상태로 넘어간다. 숫자가 100%인데 아무 일도 안 일어나는 4초보다, "처리 중"이라는 글자가 떠 있는 4초가 훨씬 덜 불안하기 때문이다.
일정 추정에도 똑같은 구조가 있다
마지막 구간이 늦어지는 구조는 진행률 표시줄만의 이야기가 아니다. Bell 연구소의 톰 카길(Tom Cargill)이 남긴 것으로 알려진 90-90 법칙은, 존 벤틀리(Jon Bentley)가 1985년 9월 Communications of the ACM 칼럼에 소개하면서 널리 퍼졌다.
코드의 처음 90%가 개발 시간의 처음 90%를 쓰고, 나머지 10%가 개발 시간의 또 다른 90%를 쓴다.
합이 180%라는 계산 실수처럼 보이지만, 사실 이게 이 농담의 핵심이다. 우리가 "거의 다 했다"고 말하는 순간의 분모 는 대개 틀려 있고, 남은 10%가 처음 추정했던 전체만큼 걸린다는 이야기다.
더글러스 호프스태터(Douglas Hofstadter)는 『괴델, 에셔, 바흐』(1979)에서 이 현상을 한 단계 더 비틀었다. 호프스태터의 법칙이라고 부르는 이 문장은 이렇다.
호프스태터의 법칙을 고려하더라도, 일은 언제나 예상보다 오래 걸린다.
이 법칙은 자기 자신을 보정 대상에 넣는다. 법칙을 반영해서 일정을 늘려도, 늘린 일정 역시 법칙의 적용을 받는다. 필자는 개발 일정에 대해 이보다 정직한 문장을 아직 보지 못했다. (그리고 이 포스팅도 예상보다 오래 걸렸다)
사람은 막대를 시계처럼 읽지 않는다
여기까지는 진행률 표시줄이 왜 틀리는지에 대한 이야기였다. 그런데 사실 사용자에게 더 중요한 건 막대가 얼마나 정확한가보다 막대를 보면서 얼마나 기다릴 만하다고 느끼는가다. 그리고 이 부분에서는 꽤 재미있는 연구들이 있다.
브래드 마이어스(Brad A. Myers)는 1985년 CHI 학회에 퍼센트 진행률 표시의 중요성에 대한 연구를 발표했다. 이 실험에서 참가자 86%가 진행률 표시가 있는 화면을 선호했는데, 흥미로운 건 표시가 정확하지 않아도 사람들이 크게 개의치 않았다는 점이다. 사람들이 원했던 건 정확한 숫자라기보다 "지금 무언가 진행되고 있다"는 신호였던 셈이다.
크리스 해리슨(Chris Harrison)과 동료들은 2007년 UIST 학회에서 같은 시간이 걸리는 진행률 표시줄을 여러 가지 움직임으로 보여주고 어느 쪽이 더 빠르게 느껴지는지 비교했다. 결과는 꽤 선명했다. 끝 무렵에 멈칫하는 막대는 일정한 속도의 막대보다 느리게 느껴졌고, 끝으로 갈수록 빨라지는 막대는 더 빠르게 느껴졌다.
같은 논문은 사람들이 시작 부분의 멈춤은 끝 부분의 멈춤보다 너그럽게 받아들인다는 결과에서 두 가지 설계를 제안한다. 초반의 진행은 작게, 끝으로 갈수록 빠르게 보여주는 것, 그리고 네트워크에서 업데이트를 받는 단계처럼 시간이 들쭉날쭉한 단계를 가능하면 앞쪽에 배치하는 것이다. 99%에서 멈추는 막대가 유독 화가 나는 이유가 여기서 설명된다. 같은 멈춤이라도 끝에서 일어나면 훨씬 길게 느껴지기 때문이다.
해리슨과 동료들은 2010년 CHI 학회에서 한 걸음 더 나아갔다. 막대 안에서 움직이는 줄무늬의 방향만 바꿔본 실험에서, 줄무늬가 진행 방향의 반대로 흐르는 막대가 체감 시간을 11% 줄여준다는 결과를 얻었다. 막대가 실제로 빨라진 건 하나도 없는데 말이다. 우리의 눈이 줄무늬의 상대 속도를 막대 자체의 속도로 착각한 결과라고 볼 수 있다.
막대는 거짓말을 해도 될까
여기서 꽤 철학적인 질문이 하나 생긴다. 체감 시간을 줄이려고 막대의 움직임을 조정하는 건 사용자를 속이는 걸까?
끝나는 시각은 그대로 두고 막대의 움직임만 바꾸는 경우를 생각해보자. 사용자가 실제로 기다리는 시간은 똑같고, 느끼는 시간만 줄어든다. 필자는 이 경우를 거짓말이라기보다 연출에 가깝다고 생각한다. 놀이공원의 대기 줄이 지그재그로 꺾여 있어서 줄이 실제보다 짧아 보이는 것과 비슷하다.
하지만 넘지 말아야 할 선은 분명히 있다. 사용자가 진행률 표시줄에서 기대하는 약속은 두 가지다. 하나는 표시값이 줄어들지 않는다는 것이고, 다른 하나는 100%가 작업이 끝난 순간에만 표시된다는 것이다. 막대가 뒤로 가거나, 일이 끝나기도 전에 100%가 뜨면 이 약속이 깨진다.
이렇게 보면 99%에서 멈춘 막대는 오히려 두 약속을 성실하게 지킨 결과일 수 있다. 추정이 틀렸다는 사실과 아직 일이 끝나지 않았다는 사실을, 막대가 할 수 있는 가장 정직한 방식으로 동시에 보여주고 있는 것이다.
// 완료 신호 전에는 99% 를 넘지 않고, 표시값은 줄어들지 않음
function nextProgress(shown: number, estimated: number, done: boolean) {
if (done) return 1;
return Math.max(shown, Math.min(0.99, estimated));
}제논의 역설
진행률 이야기를 하다 보면 고대 그리스의 철학자 제논이 떠오른다. 제논의 역설 중 가장 유명한 아킬레우스와 거북 이야기에서, 아킬레우스는 거북이 있던 자리에 닿을 때마다 거북이 조금 더 앞에 가 있어서 끝내 거북을 따라잡지 못한다.
페이지 상단에 얇게 지나가는 로딩 막대 중에는 이 역설을 그대로 코드로 옮긴 것들이 있다. 대표적인 예가 NProgress 라는 라이브러리다. NProgress 0.2.0 의 inc() 는 남은 거리에 비례하는 양만큼 막대를 늘리고, 값을 99.4%에서 멈춘다.
// nprogress.js 0.2.0 의 inc() 일부
amount = (1 - n) * clamp(Math.random() * n, 0.1, 0.95);
n = clamp(n + amount, 0, 0.994);남은 거리의 일정 비율만큼만 나아가는 걸 반복하면 막대가 어디까지 가는지는 수식으로 쓸 수 있다. 매번 남은 거리의 만큼 나아간다면, 번째 걸음 뒤의 위치는 이렇게 된다.
는 가 아무리 커져도 0이 되지 않으니, 는 1에 끝없이 가까워지기만 할 뿐 결코 1이 되지 않는다. 어디서 많이 본 공식이라고 느꼈다면 정확하다. 앞의 금요일 배포 글에서 본 "사고가 한 번 이상 날 확률" 공식과 모양이 똑같다.
그렇다면 이 막대는 어떻게 100%에 도착할까. 답은 막대 바깥에 있다. 서버 응답이 도착해서 done() 이 호출되는 순간, 막대는 한 번에 100%로 뛰어간다. 즉, 이런 막대에서 완료는 막대가 스스로 도달하는 지점이 아니라 바깥에서 들어오는 신호로만 정해진다. 아킬레우스가 거북을 따라잡는 방법은 더 빨리 달리는 게 아니라, 누군가 경기가 끝났다고 알려주는 것뿐인 셈이다.
그래서 만능일까
여기까지 읽고 나면 이런 반론이 나올 수 있다.
결국 기다림을 덜 느끼게 하려면 막대를 잘 꾸미면 된다는 이야기인가?
맞다. 사실 막대를 어떻게 움직이느냐에 따라 체감 시간이 달라진다는 건 여러 연구가 보여준 사실이다. 하지만 이 이야기를 너무 멀리 밀고 가면 반대쪽 결과가 나온다. 기다림을 줄여주는 게 언제나 좋은 건 아니라는 결과다.
라이언 뷰엘(Ryan W. Buell)과 마이클 노턴(Michael I. Norton)은 2011년 노동의 착시(labor illusion)라는 연구에서, 사람들이 결과를 기다리는 동안 시스템이 무슨 일을 하고 있는지 보여주면 같은 결과라도 더 가치 있게 느낀다는 결과를 보고했다. 여행 검색 사이트가 "항공사 120곳을 확인하는 중" 같은 문구를 보여주면, 결과가 즉시 나올 때보다 오히려 더 만족스럽게 느낀다는 것이다.
이 결과는 진행 표시의 목적이 단순히 기다림을 짧게 느끼게 하는 데만 있지 않다는 걸 알려준다. 사용자가 진짜 원하는 건 "지금 무언가 일이 일어나고 있고, 그 일이 내 요청을 위한 것"이라는 확신에 가깝다. 그래서 필자는 막대의 속도를 꾸미는 것보다 지금 무슨 단계를 지나고 있는지를 말해주는 쪽이 더 오래 신뢰를 지킨다고 생각한다. 꾸민 속도는 들키는 순간 신뢰를 잃지만, 정직한 단계 설명은 들킬 게 없기 때문이다.
다만 이 연구를 핑계로 일부러 기다림을 늘리는 건 또 다른 이야기다. 실제로는 1초면 끝나는 일을 "꼼꼼히 확인하는 중"이라며 5초씩 붙잡아두는 화면은, 앞에서 말한 두 약속 중 어디에도 걸리지 않지만 사용자의 시간을 빼앗는다는 점에서 정직하다고 보기 어렵다.
분모를 아는 만큼만 보여주자
그렇다면 실제로 진행 표시를 만들 때는 무엇을 기준으로 고르면 좋을까. 지금까지의 이야기를 정리하면 기준은 하나로 모인다. 우리가 분모 를 얼마나 알고 있는가.
| 상황 | 표시 | 근거 |
|---|---|---|
| 전체 작업량을 아는 경우 | 퍼센트 막대 | 분모가 고정되어 있음 |
| 단계 수만 아는 경우 | 3/5 단계 와 단계 이름 | 단계 길이가 달라도 표시가 틀리지 않음 |
| 전체 작업량을 모르는 경우 | 스피너, 흐르는 막대 같은 불확정 표시 | 근거 없는 숫자를 만들지 않음 |
| 결과를 나눠서 보여줄 수 있는 경우 | 스켈레톤, 부분 결과 | 진행 상황이 결과 자체로 보임 |
이 표에서 아래로 내려갈수록 막대는 덜 구체적인 숫자를 보여주지만, 그만큼 틀릴 일도 줄어든다. 진행 표시를 고르는 일은 결국 정확해 보이는 것과 정직한 것 사이에서 균형을 잡는 일인 셈이다.
분모를 정확히 안다면 퍼센트를 보여주는 게 가장 친절하다. 분모를 모르면서 퍼센트를 보여주는 건, 지도 없이 "목적지까지 37% 남았습니다"라고 말하는 내비게이션과 비슷하다. 차라리 "지금 세 번째 교차로를 지나는 중"이라고 말해주는 쪽이 정직하다.
AI 응답은 몇 퍼센트일까
이 이야기는 요즘 우리가 매일 보는 화면과도 이어진다. 대규모 언어 모델의 응답 화면이다. 모델은 토큰을 하나씩 생성하고, 종료 토큰을 생성하는 순간에야 응답의 길이가 정해진다. 즉, 생성이 끝나기 전까지 분모 를 아무도 모른다. 퍼센트를 계산할 방법이 처음부터 없는 것이다.
그래서 AI 응답 화면은 퍼센트 막대 대신 생성된 토큰을 그 자리에서 바로 보여주는 스트리밍 방식을 쓴다. 이 방식에서는 진행 상황과 결과가 같은 자리에 있다. 사용자는 완료 시점을 예측한 숫자를 보는 대신, 지금까지 만들어진 답을 읽으면서 기다린다.
앞의 표로 말하면 맨 아래 칸, 결과를 나눠서 보여줄 수 있는 경우에 해당한다. 필자는 이게 진행 표시가 도달할 수 있는 가장 정직한 형태라고 생각한다. 무엇이 얼마나 남았는지를 예측하는 대신, 무엇이 이미 끝났는지를 그대로 보여주기 때문이다.
마치며
처음 장면으로 돌아가 보자. 설치 프로그램의 막대가 99%에서 멈춘 건 프로그램이 게으름을 피워서가 아니었다. 처음에 세운 분모가 틀렸고, 마지막 구간에 미리 셀 수 없던 일들이 몰려 있었으며, 막대는 일이 끝나기 전에 100%를 보여주지 않으려고 버티고 있었던 것이다.
그래서 필자는 "왜 99%에서 멈출까"라는 질문이 사실 "우리는 처음에 전체 작업량을 얼마나 알고 있었을까"라는 질문에 더 가깝다고 생각한다. 진행률 표시줄은 작업의 진행을 보여주는 장치이기도 하지만, 그 작업에 대한 우리의 무지를 보여주는 장치이기도 하다.
다음에 진행 표시를 만들 일이 생긴다면, 퍼센트를 계산하기 전에 분모를 정말 알고 있는지부터 한 번 따져보자. 모른다면 숫자 대신 단계나 결과를 보여주는 것만으로도, 사용자의 기다림은 꽤 덜 불안해진다.
이상으로 진행률 표시줄은 왜 99%에서 멈출까 포스팅을 마친다.