Retoday에는 사용자의 브라우저 활동을 기록하는 기능이 있습니다. 브라우저 확장 프로그램이 사용자의 활동을 수집하고, 서버에 기록 생성 요청을 보내는 구조인데요. 대시보드와 리캡에서 일부 기록이 누락되는 현상을 발견하면서 기록 생성 기능을 다시 살펴보게 되었습니다.
문제 현상
발견한 문제 현상은 두 가지였습니다.
- 대시보드에서 브라우저를 끄기 직전 활동한 기록이 누락되던 문제
- 리캡에서 다음 날 새벽까지 활동했던 기록이 누락되던 문제
두 기능 모두 테스트 데이터로는 집계 로직에 문제가 없는 것을 확인했는데요. 이 결과만으로 집계 로직에 문제가 없다고 단정할 수는 없지만, 집계할 기록 자체가 누락되었을 가능성을 의심했습니다. 그래서 확장 프로그램이 언제 기록을 생성하는지부터 살펴보기로 했습니다.
기록 생성 플로우
기록 생성 과정은 다음과 같습니다.
확장 프로그램은 페이지 방문 시각을 내부적으로 저장해 두었다가, 사용자가 페이지를 종료하면 해당 시각을 포함해 기록 생성 요청을 보냅니다. 방문 시에는 별도 요청을 보내지 않고 종료 시점에만 한 번 보내므로 서버 부하를 줄일 수 있었습니다. 다만 페이지가 종료되어야 기록을 저장할 수 있다 보니, 다음과 같은 문제들이 발생할 수 있습니다.
첫 번째 문제: 기록 유실
확장 프로그램은 브라우저 내부에서 실행됩니다. 브라우저가 강제 종료되면 확장 프로그램이 기록 생성 요청을 보내지 못할 수 있는데요. 이러한 경우, 서버에는 아직 기록이 저장되지 않았으므로 해당 페이지의 기록 자체가 유실됩니다.
두 번째 문제: 집계 누락
브라우저가 정상적으로 종료되어 기록이 저장되더라도, 그 전에 집계가 실행되면 해당 기록이 빠질 수 있었습니다. 예를 들어 사용자가 자정 전부터 보던 페이지를 다음 날 새벽에 종료하면, 자정에 리캡을 생성할 때는 해당 기록이 아직 서버에 없습니다. 페이지 종료 후 매번 새로 집계하는 대시보드에는 해당 기록이 반영될 수 있지만, 이미 생성된 리캡은 재생성하지 않는 이상 누락이 그대로 남습니다.
결국 페이지 종료 시점까지 기록 저장을 미루는 것이 문제였습니다. 종료 요청을 받지 못하면 기록 자체가 남지 않고, 이후에 요청을 받더라도 집계가 먼저 끝난 뒤라면 이미 생성한 집계에는 반영되지 않을 수 있었습니다.
구조 개선
이 문제를 해결하기 위해 기록 생성 요청을 페이지 종료 시점이 아닌 방문 시점에 보내기로 했습니다. 다만 방문 시점에는 활동이 언제 끝날지 알 수 없으므로, 기록을 먼저 생성한 뒤 페이지 종료 시에 종료 시각을 갱신하도록 나눴습니다.
기존에는 한 번의 요청으로 기록이 완성됐지만, 변경한 구조에서는 두 번의 요청에 걸쳐 기록을 완성합니다.
생성 요청에서는 기록 시작 시각(startedAt)을 저장하고, 종료 요청에서는 기록 종료 시각(endedAt)을 갱신합니다.
생성 요청이 성공한 뒤라면 브라우저가 강제 종료되어도 서버에 기록은 남아 있습니다.
집계 전처리
기록을 방문 시점에 생성하면서 리캡 생성 배치나 대시보드 집계에서도 아직 종료되지 않은 기록(이하 활성 기록)을 조회할 수 있게 되었습니다.
이런 기록까지 집계에 포함하려면 endedAt이 null일 때 활동 시간을 어떻게 계산할지 정해야 하는데요.
집계에서는 종료 시각이 없는 기록을 현재 시각까지 이어진 것으로 계산하도록 했습니다.
한계점
현재 시각까지 활동한 것으로 계산해도 되는 것은 사용자가 실제로 페이지를 보고 있을 때입니다. 브라우저가 강제 종료되어 종료 요청을 보내지 못한 경우에도 종료 시각이 없는 기록이 남기 때문에, 두 상황을 구분하지 못하면 집계에 문제가 생깁니다.
종료 요청이 누락되면 endedAt은 계속 null로 남습니다.
이 기록을 매번 현재 시각까지 활동한 것으로 계산하면, 실제로는 브라우저를 사용하지 않은 시간까지 집계됩니다.
따라서 종료 시각이 없는 기록 중에서 실제 활동이 끝난 기록을 찾아 종료 처리할 방법이 필요했습니다.
첫 번째 대안: 기록 생성 전처리
처음에는 기록 생성 요청 전에 기존의 활성 기록들을 비정상 기록으로 간주하고 강제 종료 처리하는 방법을 생각했습니다. 현재 서비스에서는 사용자가 임의의 시점에 반드시 한 페이지만 볼 수 있다는 전제가 있기 때문에 적용할 수 있는 방법입니다.
다만 이 방법은 실제 종료 시각을 알기 어려워 실제 종료 시각과의 오차 범위를 예상할 수 없다는 단점이 있습니다.
실제 종료 시각은 t = 10이지만 재방문 시각이 t = 60이므로 최종 종료 시각은 t = 60으로 보정됩니다.
결과적으로 재방문 시점이 늦어질수록 실제 종료 시각과의 오차가 계속 증가하는 구조입니다. 즉, 재방문 시점에 상한이 없다면 종료 시각의 오차 범위 역시 이론적으로 제한 없이 커질 수 있습니다. 이러한 오차 범위를 줄이려면 실제 종료 시각과 관련된 정보가 필요했습니다.
두 번째 대안: 하트비트
그래서 활동이 이어지는 동안 확장 프로그램이 서버에 주기적으로 신호를 보내도록 하는 하트비트 방법을 생각했습니다. 마지막 하트비트를 받은 시각을 알면, 사용자가 적어도 언제까지 활동했는지 확인할 수 있습니다. 재방문을 기다리는 방법과 달리 종료 시각을 보정할 기준이 생기는 것입니다.
클라이언트는 주기적으로 하트비트 요청을 보내고 서버에서는 주기적으로 하트비트를 체크합니다. 이때, 하트비트가 만료된 기록은 비정상 기록으로 간주하고 종료 시각을 마지막 하트비트 갱신 시각으로 보정합니다.
마지막 하트비트 갱신 시각을 기준으로 종료 시각을 보정하므로, 실제 종료 시각과의 오차 범위를 이해하려면 하트비트 주기와 만료 시간을 구분해서 볼 필요가 있습니다. 예를 들어, 하트비트 주기가 10이고 만료 시간이 30인 경우를 보겠습니다.
실제 종료 시각은 t = 15이지만 마지막 하트비트 갱신 시각은 t = 10이므로, 최종적으로 종료 시각은 t = 10으로 보정됩니다.
그러나 서버는 마지막 하트비트 갱신 이후 만료 시간이 지나기 전까지 해당 기록을 활성 상태로 판단합니다.
따라서 실제로는 t = 15에 종료되었더라도 t = 40까지는 일시적으로 활성 기록으로 집계될 수 있습니다.
만료를 확인하기 전까지는 활동 시간이 실제보다 길게 집계될 수 있습니다. 종료 시각을 마지막 하트비트 시각으로 보정한 뒤에는, 하트비트가 정상적으로 전달되었다는 전제에서 최종 오차를 한 주기 이내로 줄일 수 있습니다.
물론 네트워크 지연이나 전송 누락이 있으면 마지막 갱신 시각과 실제 종료 시각의 차이는 더 커질 수 있습니다. 하트비트가 끊겼다는 사실만으로 비정상 기록이라는 것을 확정할 수도 없으므로, 만료 시간은 일시적인 통신 장애를 어느 정도 허용할지 고려해 결정해야 합니다.
주기적인 하트비트 갱신 요청과 만료 확인으로 부하가 늘어난다는 단점도 있습니다. 만약 갱신 주기를 더 짧게 하면 정확성이 늘어나지만 그만큼 부하도 늘어납니다. 대시보드와 리캡에서는 실제 활동 시간에 가까운 기록이 필요했습니다. 추가 요청이 발생하더라도 종료 시각의 오차를 제한할 수 있고, 갱신 주기로 부하를 조절할 수 있다는 점에서 하트비트를 적용하기로 했습니다.