조회수는 숫자가 아니라 정의다
박어진
개발 프로젝트FrontendBackend
1. 도입 — 이 기사의 조회수는 몇 회일까?
문제. 이 기사의 조회수는 몇 회일까요? ① 100 ② 3 ③ 7
정답은 "전부"입니다. 그리고 그게 버그였습니다.
9월, 우리 뉴스 매거진(bizcru.sh)에서 같은 기사가 화면마다 다른 조회수를 달고 있었다. 한국어 상세에선 100회, 영어 상세에선 3회, 인기 목록에선 7회. 셋 다 서버가 정직하게 계산한 값이었다. 틀린 숫자는 하나도 없었는데, 화면은 틀려 보였다.

- 상황 / 오해: 조회수는 "+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일, 한국어+영어 합 |

- 반전: 처음 대응은 "라벨을 나눠 적자"였다. 하지만 독자에게 같은 기사는 하나다. 라벨을 달아도 숫자가 셋이면 결함으로 읽힌다. 게다가 영어판은 한국어 확정본의 번역이라, 언어별로 나눠 센 것부터가 틀렸다. 9월 초의 처방도 숫자를 맞추려다 기준을 하나 더 화면에 올린 셈이었다.
- 판결: 조회수는 하나 — "누적, 한국어+영어 합". 최근 2일 기준은 폐기했다.
- 기록은 건드리지 않고, 읽는 쪽(DB 뷰)에서 한국어·영어 형제 기사의 조회를 묶었다. 되돌릴 때 뷰 정의 하나만 바꾸면 되도록.
- 옛 프론트가 보내는
?days=파라미터는 에러 없이 무시한다. 프론트 배포 순서가 API 배포를 붙잡지 않게.

- 범인은 버그가 아니라, 기준이 셋이라는 사실이었다.
- → 교훈: 숫자가 다를 땐 순서와 값을 따로 대조한다. 순서가 같으면 계산이 아니라 정의가 다르다.
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회 기사는 애초에 없다
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. 꿈쩍 않는 1 | 3번 열면 조회 기록 1회 | 3번 열면 조회 기록 3회 |
| 3. 사라진 0 | 게재 5편 중 4편 보고 | 5편 전부 보고 (발송 전) |
- 한계 / 트레이드오프 (솔직하게):
- 중복 제거를 없앤 대가: 새로고침·반복 클릭·스크립트로 숫자를 부풀리는 걸 막는 장치가 지금은 없다. 그래서 이 숫자는 순위와 표시용 "신호"로만 쓰고, 정산이나 계약 증빙으로 쓰지 않기로 했다. 협약 매체 리포트의 조회수도 참고치로 다룬다.
- "오늘의 인기"가 "누적 인기"가 됐다. 순위는 최근 2일로 두고 숫자만 누적으로 바꾸는 안도 있었지만, "화면 어디서든 숫자는 하나"를 우선했다.
- 되돌릴 길은 남겼다. 쓰지 않게 된 중복 제거 테이블은 지우지 않았다. 다시 필요해지면 서버 함수 한 곳만 고치면 된다.
6. 마치며
한 줄 요약: 숫자가 이상할 때 먼저 물을 것은 "어디가 고장 났나"가 아니라 "누가 무엇을 세고 있나"다.
처음 퀴즈의 답이 "전부"였던 이유도 여기에 있다. 셋 다 맞는 숫자였고, 그래서 문제였다. 이제 그 기사의 조회수를 물으면 답은 하나다.
실천 체크리스트