본문으로 건너뛰기

조회수는 숫자가 아니라 정의다

박어진
개발 프로젝트FrontendBackend

1. 도입 — 이 기사의 조회수는 몇 회일까?

문제. 이 기사의 조회수는 몇 회일까요? ① 100 ② 3 ③ 7
정답은 "전부"입니다. 그리고 그게 버그였습니다.

9월, 우리 뉴스 매거진(bizcru.sh)에서 같은 기사가 화면마다 다른 조회수를 달고 있었다. 한국어 상세에선 100회, 영어 상세에선 3회, 인기 목록에선 7회. 셋 다 서버가 정직하게 계산한 값이었다. 틀린 숫자는 하나도 없었는데, 화면은 틀려 보였다.

bizcru.sh 인기 목록. 기사마다 붙은 '조회 N', 이 숫자 하나가 이 글의 주인공이다.
bizcru.sh 인기 목록. 기사마다 붙은 '조회 N', 이 숫자 하나가 이 글의 주인공이다.
  • 상황 / 오해: 조회수는 "+1 하고 보여 주면 끝"인 숫자처럼 보인다. 그래서 숫자가 이상하면 다들 카운터부터 의심한다.
  • 왜 아픈가: 독자는 숫자가 셋이면 셋 다 믿지 않는다. 게다가 이 숫자는 협약 매체에 보내는 월간 리포트에도 들어간다.
  • 이 글에서 다룰 것: 9월부터 10월 초까지 받은 조회수 제보 세 건. 셋 다 카운터 고장이 아니라 "정의"의 문제였다.
    • 사건 1. 세 개의 정답 — 100·3·7
    • 사건 2. 꿈쩍 않는 1 — 새로고침해도 안 오르는 조회수
    • 사건 3. 사라진 0 — 합계는 맞는데 기사가 모자란 리포트

2. 문제 정의 — 조회수 하나에 숨은 결정 셋

조회수는 숫자 하나처럼 보이지만, 그 뒤에는 누군가 내린 결정이 적어도 세 개 겹쳐 있다. 우리 사건 셋은 정확히 이 셋에서 하나씩 나왔다.

결정질문어긋나면사건
무엇을 셀까 (기준)한국어판과 영어판은 같은 기사인가? 누적인가, 최근 N일인가?같은 기사에 숫자가 여러 개사건 1
어디서 거를까 (중복)새로고침은 한 번인가 여러 번인가? 그 규칙은 어디에 있나?숫자가 안 움직임사건 2
어디서부터 셀까 (출발점)조회 기록에서 출발하나, 기사 목록에서 출발하나?0인 행이 사라짐사건 3
  • 통념 vs 실제: 통념은 "조회수 버그 = 카운터가 고장 났다". 실제로 세 건 모두 카운터는 멀쩡했다. 고장 난 건 같은 숫자를 서로 다르게 이해하고 있었다는 점이다.

3. 핵심 개념 — 조회수는 정의다

  • 한 문장 정의: 조회수는 측정값이 아니라 "무엇을 세고, 어디서 거르고, 어디서부터 셀지"에 대한 합의다.
  • 비유: 가게 매출을 본점만, 분점만, 최근 이틀 합으로 따로 보고하는 것과 같다. 셋 다 맞는 숫자인데, 사장님이 보기엔 장부가 엉망이다.
  • 이 원칙을 세우는 이유: 숫자가 이상할 때 "어디가 고장 났나"보다 "누가 무엇을 세고 있나"를 먼저 물으면 진단이 훨씬 빨라진다. 아래 세 사건이 그 증거다.

4. 사건 파일 — 범인은 매번 버그가 아니었다

사건마다 같은 순서로 적는다. 장면 → 첫 용의자 → 단서 → 진범 → 판결.

4-1. 사건 1. 세 개의 정답 — 100·3·7

  • 장면: 같은 기사가 한국어 상세 100회, 영어 상세 3회, 인기 목록 7회.
  • 첫 용의자: 프론트의 표시 버그. 전조도 있었다. 9월 초 인기 1위가 화면에선 86회인데 서버 누적값은 34회였다. 그때 처방은 "인기 목록에선 기간 조회수(window_view_count)를 쓰세요"였다.
  • 단서: 화면 1~8위의 순서는 서버 응답과 완전히 같았다. 정렬은 멀쩡하고 숫자만 다르다. 계산이 아니라 "어떤 값을 보여 주느냐"가 다르다는 뜻이다.
  • 진범: API가 내는 조회수 기준이 셋이었다.
화면 값출처의미
100한국어 기사 행의 view_count한국어판만의 누적
3영어 기사 행의 view_count영어판만의 누적 (행 ID가 달라서 따로 쌓였다)
7인기 API의 window_view_count최근 2일, 한국어+영어 합
사건 1 한눈에 보기: 같은 기사에 붙은 세 숫자와, 기준을 하나로 합친 뒤
사건 1 한눈에 보기: 같은 기사에 붙은 세 숫자와, 기준을 하나로 합친 뒤
  • 반전: 처음 대응은 "라벨을 나눠 적자"였다. 하지만 독자에게 같은 기사는 하나다. 라벨을 달아도 숫자가 셋이면 결함으로 읽힌다. 게다가 영어판은 한국어 확정본의 번역이라, 언어별로 나눠 센 것부터가 틀렸다. 9월 초의 처방도 숫자를 맞추려다 기준을 하나 더 화면에 올린 셈이었다.
  • 판결: 조회수는 하나 — "누적, 한국어+영어 합". 최근 2일 기준은 폐기했다.
    • 기록은 건드리지 않고, 읽는 쪽(DB 뷰)에서 한국어·영어 형제 기사의 조회를 묶었다. 되돌릴 때 뷰 정의 하나만 바꾸면 되도록.
    • 옛 프론트가 보내는 ?days= 파라미터는 에러 없이 무시한다. 프론트 배포 순서가 API 배포를 붙잡지 않게.
지금의 bizcru.sh. 기사 상세와 옆 인기 목록이 같은 숫자(조회 174)를 보여 준다.
지금의 bizcru.sh. 기사 상세와 옆 인기 목록이 같은 숫자(조회 174)를 보여 준다.
  • 범인은 버그가 아니라, 기준이 셋이라는 사실이었다.
  • → 교훈: 숫자가 다를 땐 순서와 값을 따로 대조한다. 순서가 같으면 계산이 아니라 정의가 다르다.

4-2. 사건 2. 꿈쩍 않는 1 — 문지기가 둘이었다

  • 장면: 새로고침을 아무리 눌러도 조회수가 그대로였다.
  • 첫 용의자: 서버의 중복 제거. 실제로 서버에는 "같은 사람·같은 기사·같은 날은 1회만" 규칙이 있었다. 9월 21일, 이 규칙을 통째로 뺐다. 이제 요청마다 +1이다.
    • 선택지는 셋이었다. ⓐ 완전히 푼다 ⓑ 중복 창을 10분쯤으로 줄인다 ⓒ 제한은 프론트 쪽이니 API는 그대로 둔다. ⓒ는 "실체가 우리 서버 규칙"이라는 이유로 기각했다.
  • 2주 뒤, 같은 제보가 다시 왔다. 여전히 안 오른다.
  • 단서:
    • API를 직접 호출하면 0 → 1로 바로 올랐다. 서버는 정상이다.
    • 프론트 빌드 파일을 내려받아 읽었다. localStorage에 조회 시각을 적어 두고, 24시간 안이면 조회 기록 전송 자체를 건너뛰는 코드가 있었다.
    • 하나 더: 화면 숫자는 페이지를 그릴 때 가져오고, 조회 기록은 그 뒤에 보낸다. 방금 본 1회는 언제나 한 박자 늦게 보인다.
  • 진범: 중복 제거가 두 겹이었다. 서버 규칙을 걷어 내도, 프론트 문지기가 같은 일을 하고 있었다. 기각했던 ⓒ가 반은 맞았던 셈이다.
서버 문을 열어도 프론트 문이 닫혀 있으면 숫자는 그대로다.
서버 문을 열어도 프론트 문이 닫혀 있으면 숫자는 그대로다.
  • 판결: 프론트의 24시간 제한도 걷어 내서, 중복 규칙을 한 곳에서만 정하게 했다. 지금 그 규칙은 "거르지 않는다"이다.
  • 검증: 목업 서버와 실제 Chrome으로 같은 기사를 3번 열었다. 조회 기록 전송 1회 → 3회.
    • 웃픈 함정: 처음 검증에 쓴 자동화 브라우저(headless)는 봇 필터에 걸려 집계가 안 됐다(counted: false). 내 테스트 봇이 정말로 봇 취급을 받은 것이다. 일반 브라우저 정보로 바꿔 다시 쟀다.
  • 범인은 버그가 아니라, 문지기가 둘이라는 사실이었다.
  • → 교훈: 같은 규칙이 두 곳에 있으면 한쪽만 고쳐서는 증상이 안 변한다. 규칙을 고치기 전에, 그 규칙이 또 어디에 있는지부터 찾는다.

4-3. 사건 3. 사라진 0 — 합계는 맞았다

  • 장면: 일본 협약 매체에 보낼 9월 월간 리포트. 그 달 게재 기사는 5편인데, 리포트에는 4편이 찍혀 있었다.
  • 왜 무서운가: 계약상 게재 기사 전체를 보고해야 하는 리포트였다. 이대로 보내면 틀린 계약 리포트가 된다. 발송 마감까지 9일.
  • 첫 용의자: 리포트 검증. 그런데 검증은 통과했다. "조회 합계 = 표 합계".
  • 단서: 빠진 한 편은 9월에 한 번도 읽히지 않은 기사였다. 리포트 목록은 조회 기록 테이블에서 출발해 만들어지고 있었다.
  • 진범: 출발점. 조회 기록에서 세기 시작하면, 조회 0회인 기사는 처음부터 목록에 없다. 그리고 0은 더해도 합계가 같다. 합계 검증도 같은 데이터에서 출발하니 누락을 잡을 수 없었다.
조회 기록에서 출발하면 0회 기사는 목록에 오르지도 못한다. (예시 값)
조회 기록에서 출발하면 0회 기사는 목록에 오르지도 못한다. (예시 값)

단순화하면 이런 차이다.

-- ❌ 조회 기록에서 출발: 0회 기사는 애초에 없다
SELECT article_id, SUM(views) AS views
FROM article_view_daily
WHERE day >= '2026-09-01' AND day < '2026-10-01'
GROUP BY article_id;

-- ✅ 대상 목록에서 출발: 0회도 0으로 남는다
SELECT a.id, COALESCE(SUM(v.views), 0) AS views
FROM articles a
LEFT JOIN article_view_daily v
  ON v.article_id = a.id
 AND v.day >= '2026-09-01' AND v.day < '2026-10-01'
WHERE a.id IN (/* 9월 게재 기사 ∪ 9월 조회 기사 */)
GROUP BY a.id;
  • 판결: 리포트 목록을 "그 달 조회된 기사 ∪ 그 달 게재된 기사"로 바꾸고, 조회가 없으면 0으로 채웠다. 수정 전 코드에서 새 테스트가 실패하는 걸 먼저 확인하고 고쳤다. 재실행하니 5편이 전부 나왔다. 마감 전에 막았다.
  • 범인은 버그가 아니라, 출발점이었다.
  • → 교훈: "없음"까지 보고해야 하는 리포트는 기록이 아니라 대상 목록에서 출발한다. 합계 검증은 누락을 못 잡는다. 개수도 같이 맞춘다.

5. 검증 / 결과

사건전후
1. 세 개의 정답같은 기사에 숫자 3개, 한국어·영어 숫자가 다른 짝 44쌍숫자 1개, 어긋난 짝 0쌍
2. 꿈쩍 않는 13번 열면 조회 기록 1회3번 열면 조회 기록 3회
3. 사라진 0게재 5편 중 4편 보고5편 전부 보고 (발송 전)
  • 한계 / 트레이드오프 (솔직하게):
    • 중복 제거를 없앤 대가: 새로고침·반복 클릭·스크립트로 숫자를 부풀리는 걸 막는 장치가 지금은 없다. 그래서 이 숫자는 순위와 표시용 "신호"로만 쓰고, 정산이나 계약 증빙으로 쓰지 않기로 했다. 협약 매체 리포트의 조회수도 참고치로 다룬다.
    • "오늘의 인기"가 "누적 인기"가 됐다. 순위는 최근 2일로 두고 숫자만 누적으로 바꾸는 안도 있었지만, "화면 어디서든 숫자는 하나"를 우선했다.
    • 되돌릴 길은 남겼다. 쓰지 않게 된 중복 제거 테이블은 지우지 않았다. 다시 필요해지면 서버 함수 한 곳만 고치면 된다.

6. 마치며

한 줄 요약: 숫자가 이상할 때 먼저 물을 것은 "어디가 고장 났나"가 아니라 "누가 무엇을 세고 있나"다.

처음 퀴즈의 답이 "전부"였던 이유도 여기에 있다. 셋 다 맞는 숫자였고, 그래서 문제였다. 이제 그 기사의 조회수를 물으면 답은 하나다.

실천 체크리스트