
방문자 대부분이 글의 30% 지점에서 이탈하고 있었다. 오랜 시간 공들여 쓴 글인데 스크롤 깊이 지표가 그걸 보여줬을 때 솔직히 좀 허탈했다. 숫자가 나를 판단하는 것 같은 느낌이랄까. 그런데 동시에 글이 문제인지 구조가 문제인지 알 수 없어서, 일단 독자가 어디까지 읽었는지 눈으로라도 보여주는 장치를 달아보고 싶었다. 프로그레스 바가 완독률을 높인다는 보장은 어디에도 없었지만, 달기 전에 효과를 따지는 건 써보지도 않고 리뷰를 쓰는 것과 마찬가지라 일단 만들어보기로 했다. 상단에 얇은 바 하나 올리는 게 뭐가 어렵겠냐 싶었는데, 실제로 해보니 생각보다 손이 꽤 많이 갔다.
scroll 이벤트와 requestAnimationFrame 사이에서 배운 것
처음 코드를 짤 때 scroll 이벤트 핸들러 안에서 바로 DOM을 수정했다. 스크롤하는 순간 화면이 뚝뚝 끊겼고, 콘솔을 열어보니 이벤트가 초당 수십 번씩 발생하고 있었다. scroll 이벤트는 사용자가 페이지를 스크롤할 때마다 브라우저가 연속으로 발생시키는 신호인데, 문제는 이 신호가 너무 잦아서 DOM 조작이 렌더링 주기를 따라가지 못한다는 점이다.
솔직히 이게 왜 문제인지 처음엔 잘 몰랐다. 이벤트가 자주 발생하면 UI도 자주 갱신되는 거 아닌가 싶었는데, 오히려 반대였다. 브라우저는 화면을 그리는 타이밍이 따로 있는데, 그 타이밍과 상관없이 DOM을 계속 건드리면 브라우저가 매번 레이아웃을 다시 계산해야 한다. 이걸 reflow라고 하는데, 자주 일어날수록 성능이 떨어진다.
해결책은 requestAnimationFrame이었다. 브라우저가 다음 화면을 그리기 직전에 딱 한 번만 지정한 함수를 실행하도록 예약하는 API다. 쉽게 말하면 "브라우저야, 그림 그릴 때 이것도 같이 처리해줘"라고 요청하는 방식이다. scroll 이벤트가 아무리 많이 발생해도 실제 DOM 업데이트는 프레임당 한 번으로 제한되니 렌더링 부하가 확실히 줄었다. 직접 적용해보니 끊김이 완전히 사라졌다. 사실 이 정도 차이가 날 줄 몰랐다. 개념은 알고 있었는데 실제로 체감하니 그 차이가 확연했다.
두 번째 실수는 퍼센트 계산이었다. scrollHeight를 그냥 나누면 글 하단 여백이 한참 남아 있는데도 바가 90%를 넘기는 이상한 상황이 생긴다. 정확한 공식은 scrollHeight에서 innerHeight를 빼는 것이다. innerHeight는 현재 브라우저 창에서 실제로 보이는 영역의 높이인데, 전체 문서 길이에서 화면에 보이는 부분을 빼야 "실제로 스크롤 가능한 거리"가 나오고, 그 값으로 나눠야 100%가 정확히 글 끝에서 채워진다. 이 계산 오류를 잡고 나서야 바가 제대로 작동했다. 수식 하나 차이인데 결과물이 전혀 달랐다.
구현하면서 챙겨야 할 핵심은 이렇다. scroll 이벤트 핸들러 안에서 직접 DOM을 수정하지 않는 것, requestAnimationFrame으로 렌더링 타이밍을 브라우저에 위임하는 것, 퍼센트 계산식은 반드시 scrollTop을 (scrollHeight에서 innerHeight를 뺀 값)으로 나눠 100을 곱하는 것. 그리고 모바일 환경에서는 passive 이벤트 리스너를 적용하는 것이다. passive 이벤트 리스너는 "이 핸들러는 스크롤을 막지 않을 것"이라고 브라우저에 미리 알려주는 옵션인데, 브라우저가 기본 스크롤 동작을 지연 없이 처리할 수 있게 되어 모바일에서 특히 체감 속도가 달라진다.
티스토리에 넣으면서 헤맸던 부분
티스토리에 코드를 넣는 것도 예상보다 헤맸다. 처음에 script 태그를 head 안에 배치했더니 getElementById가 계속 null을 반환했다. DOM이 아직 로드되지 않은 상태에서 스크립트가 실행됐기 때문이다. DOM은 브라우저가 HTML 문서를 읽고 나서 만들어내는 객체 구조인데, 스크립트가 실행될 시점에 그 구조가 완성되어 있어야 바 요소를 찾을 수 있다.
해결은 단순했다. 닫는 body 태그 바로 앞으로 script를 옮겼더니 바로 작동했다. 당연한 얘기인데 직접 겪기 전까지는 잘 모른다. div는 body 태그 다음에, script는 닫는 body 태그 앞에 넣는 구조가 티스토리에서 오류 없이 작동하는 패턴이다. 여러 번 테스트해보고 정착한 방식이다.
색상은 처음에 유명 블로그 색을 그냥 따라 썼는데 블로그에 얹혀 보이는 느낌이 심했다. 프로그레스 바가 튀어 보이면 독자가 글 대신 바를 의식하게 된다. 스포이드로 블로그 메인 컬러를 직접 따서 바꿨더니 훨씬 자연스러워졌다. 높이도 3px, 4px, 5px을 직접 배포해보면서 눈으로 고른 결과 4px에 정착했다. 수치 하나를 결정하는 데도 생각보다 품이 든다.
효과에 대한 솔직한 생각
프로그레스 바가 완독률에 긍정적 영향을 준다는 근거가 전혀 없는 건 아니다. 진행 상황을 시각적으로 보여주는 것이 사용자의 지속 행동을 유도한다는 점은 UX 연구에서도 다뤄지는 원리다. 그런데 솔직히 말하면, 이건 맹신할 수 없다.
내 경험상 짧은 글에 바를 달면 "이게 왜 있지?" 하는 느낌을 줄 수 있다. 2천 자 미만 글에서는 효과가 거의 없었고, 긴 튜토리얼이나 아티클에서만 체감 차이가 있었다. 바가 있다는 것 자체가 독자에게 "이 글은 좀 길다"라는 신호를 준다. 그 신호가 도움이 될지 방해가 될지는 글의 성격에 달려 있다.
모바일에서는 상단 고정 바가 브라우저 UI와 겹쳐 보이는 경우도 있어서 z-index 설정을 세심하게 잡아야 한다. z-index는 화면 위에서 요소들이 쌓이는 순서를 지정하는 CSS 속성인데, 이 값을 충분히 높게 잡지 않으면 다른 요소에 가려지는 문제가 생긴다. 이건 데스크탑에서 테스트할 때는 잘 안 보이다가 실제 모바일로 확인하면 발견되는 유형이라 반드시 직접 기기로 확인해야 한다.
결론적으로, 프로그레스 바를 구현하는 것 자체는 어렵지 않다. 하지만 scroll 이벤트 최적화, 퍼센트 계산 정확도, 플랫폼별 삽입 위치, 디자인 통일감까지 챙기다 보면 단순한 기능 하나에 꽤 많은 시간이 들어간다. 긴 글을 주로 쓰는 블로그라면 한 번쯤 달아볼 만하다. 단, 짧은 글 위주라면 굳이 공을 들일 필요는 없다. 먼저 자신의 대표 글 길이를 기준으로 필요 여부를 판단하는 것이 순서다.
그리고 한 가지 더 말하고 싶은 건, 이 기능 자체의 효용보다 만들면서 배운 것들이 더 값졌다는 점이다. requestAnimationFrame의 작동 방식을 이론으로만 알고 있던 것과 직접 끊김을 경험하고 나서 아는 것은 완전히 다르다. 직접 부딪쳐보지 않으면 알 수 없는 것들이 분명히 있다.
참고:
MDN Web Docs – scroll event: https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event
MDN Web Docs – requestAnimationFrame: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
Google Web Fundamentals – Passive Event Listeners: https://developers.google.com/web/tools/lighthouse
Nielsen Norman Group – Progress Indicators: https://www.nngroup.com/articles/progress-indicators/
CSS-Tricks – Reading Position Indicator: https://css-tricks.com/reading-position-indicator/