조회수가 실제보다 8배 부풀려졌다 — 블로그 중복 방지 시스템 설계와 구현기
조회수가 실제보다 8배 부풀려졌다 — 블로그 중복 방지 시스템 설계와 구현기#
블로그를 운영하다 보면 자연스럽게 지표를 들여다보게 됩니다. 방문자가 늘고 있는지, 어떤 글이 인기 있는지, 유입 채널은 어딘지. 그런데 어느 날 Google Analytics 4 대시보드를 열었을 때 이상한 숫자가 눈에 들어왔습니다.
특정 포스트 하나의 뷰/유저 비율이 8.0배였습니다.
한 명이 같은 글을 평균 여덟 번 읽는다는 뜻입니다. 저희 블로그의 다른 글들이 평균 1.2~1.8배인 걸 감안하면 명백한 이상 수치였습니다. 처음엔 "혹시 정말 그 글이 그렇게 좋았나?" 싶었지만, 코드를 뜯어보니 문제는 명확했습니다. 새로고침할 때마다, 탭을 닫았다 열 때마다, 조회수가 무조건 1씩 올라가고 있었습니다.
이 글은 그 문제를 발견하고, 원인을 분석하고, 시스템 수준에서 해결한 과정을 기록합니다. 기술적인 결정마다 "왜 그렇게 했는가"를 최대한 솔직하게 담았습니다.
1. 문제 발견 — GA4 데이터가 보낸 신호#
GA4에서 뷰 수(Views)와 유저 수(Users) 를 나눠서 보면 콘텐츠 소비 패턴을 읽을 수 있습니다. 좋은 글은 사람들이 다시 찾아오기 때문에 이 비율이 1보다 높은 건 자연스럽습니다. 하지만 8.0배는 다른 이야기입니다.
데스크탑 사용자 기준으로 Post #5의 지표를 다른 포스트들과 비교해봤습니다:
| 포스트 | Views | Users | 비율 |
|---|---|---|---|
| Post #1 | 142 | 98 | 1.45 |
| Post #3 | 89 | 61 | 1.46 |
| Post #5 | 320 | 40 | 8.0 |
| Post #7 | 201 | 134 | 1.50 |
Post #5만 유독 튀는 이유는 단순했습니다. 당시 팀원들이 개발 중에 자주 열어보던 글이었습니다. 새로고침, 수정 확인, 링크 공유 테스트 — 이 모든 행위가 조회수로 잡혔습니다.
기존 코드의 구조적 한계#
기존 구현은 단순했습니다. API 엔드포인트에 GET 요청이 오면 무조건 viewCount + 1을 실행했습니다.
// 기존 코드 (단순 카운트 증가)
fun getBlogPost(id: Long): BlogPost {
val post = blogPostRepository.findById(id)
?: throw NotFoundException("포스트를 찾을 수 없습니다")
post.viewCount += 1
return blogPostRepository.save(post)
}
문제는 이 코드가 "누가 봤는가"를 전혀 추적하지 않는다는 겁니다. 같은 사람이 새로고침 10번을 하든, 10명이 각각 한 번씩 보든 결과는 동일하게 +10이었습니다. 조회수가 사용자 수가 아니라 HTTP 요청 수를 세고 있었던 것입니다.
2. 해결 전략 수립 — 누구를 어떻게 식별할 것인가#
문제를 해결하려면 먼저 "사용자를 어떻게 식별할 것인가"를 결정해야 했습니다. 크게 세 가지 경우를 구분했습니다.
- 로그인 사용자: JWT 토큰에서
userId를 추출 → 명확한 식별 가능 - 비로그인 사용자: 식별 수단이 없음 → 별도 전략 필요
- Unknown(헤더/토큰 없음): 크롤러, 자동화 도구 등 → 카운트 제외
로그인 사용자는 비교적 간단합니다. 이미 인증된 userId가 있으니 그 기준으로 중복 체크를 하면 됩니다. 문제는 비로그인 사용자입니다.
비로그인 사용자를 어떻게 식별할까#
몇 가지 옵션을 검토했습니다.
IP 주소 기반: 가장 단순한 방법이지만, NAT 뒤에 있는 회사 네트워크에서는 수십 명이 같은 IP를 씁니다. 반대로 유동 IP 환경에서는 같은 사람도 매번 다른 IP로 잡힙니다. 정확도가 낮고, GDPR/개인정보 이슈도 있습니다.
세션 기반: 세션은 브라우저를 닫으면 사라집니다. 재방문 중복을 막기 어렵습니다.
Cookie 기반: localStorage보다 서버에서 제어하기 쉽지만, 사용자가 쿠키를 거부하거나 삭제할 수 있습니다.
localStorage + UUID 기반: 브라우저에 고유 ID를 저장합니다. 세션보다 지속성이 높고, 구현이 단순합니다. 단점은 localStorage를 직접 지우거나 시크릿 모드를 쓰면 우회된다는 점입니다.
저희는 localStorage + UUID를 선택했습니다. 완벽한 방법은 없고, 저희의 목표는 "진짜 중복 카운트를 줄이는 것"이지 "절대로 뚫리지 않는 방어막"을 만드는 게 아니었기 때문입니다. localStorage 우회는 의도적인 행위가 필요하고, 그런 경우는 전체 트래픽 중 미미한 비율입니다.
UUID를 그대로 저장하지 않고 SHA-256 해시를 쓴 이유#
처음엔 UUID를 그냥 저장하려 했습니다. 그런데 한 가지가 걸렸습니다. UUID는 사용자를 추적할 수 있는 식별자입니다. DB에 저장된 UUID가 유출되거나, 다른 시스템과 조인되면 개인을 특정할 수 있는 수단이 될 수 있습니다.
SHA-256 해시를 쓰면 원본 UUID를 복원할 수 없습니다. DB에 저장된 해시값은 익명성을 유지하면서도 "이 브라우저가 이 포스트를 봤는가"를 체크하는 데 충분합니다. 단방향 해시이므로 역추적이 불가능합니다.
const ANONYMOUS_ID_KEY = "weggle_anonymous_id";
async function hashUUID(uuid: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(uuid);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
export const getOrCreateAnonymousId = async (): Promise<string | null> => {
if (typeof window === "undefined") return null; // SSR 환경 방어
const existing = localStorage.getItem(ANONYMOUS_ID_KEY);
if (existing) return existing;
const uuid = crypto.randomUUID();
const hashed = await hashUUID(uuid);
localStorage.setItem(ANONYMOUS_ID_KEY, hashed);
return hashed;
};
typeof window === "undefined" 체크는 Next.js의 SSR 환경에서 window 객체가 없어 발생하는 오류를 방지합니다. 이 함수는 클라이언트 사이드에서만 실행되어야 합니다.
crypto.subtle.digest는 브라우저 내장 Web Crypto API입니다. 외부 라이브러리 없이 SHA-256 해시를 생성할 수 있습니다. crypto.randomUUID()도 마찬가지로 브라우저 내장 API이며, RFC 4122 표준을 따르는 UUID v4를 생성합니다.
3. Kotlin sealed class로 ViewerId 표현하기#
백엔드에서 "누가 봤는가"를 표현하는 방법을 고민했습니다. 처음에는 단순히 nullable 파라미터로 처리하려 했습니다.
// 안 좋은 예시 - nullable 파라미터
fun getBlogPostAndIncrementView(id: Long, userId: Long?, anonymousId: String?)
이 방식은 userId도 null이고 anonymousId도 null인 경우, 둘 다 값이 있는 경우 등 잘못된 상태가 컴파일 수준에서 허용됩니다. 런타임에 예외를 던지거나 조건문이 복잡해집니다.
enum class도 검토했지만, enum은 각 케이스가 다른 데이터를 가질 수 없습니다. User는 userId: Long이 필요하고, Anonymous는 anonymousId: String이 필요한데, enum으로는 이를 타입 안전하게 표현하기 어렵습니다.
sealed class가 정답이었습니다.
sealed class ViewerId {
data class User(val userId: Long) : ViewerId()
data class Anonymous(val anonymousId: String) : ViewerId()
object Unknown : ViewerId()
}
sealed class는 Kotlin에서 대수적 타입(Algebraic Data Type) 을 표현하는 방법입니다. 세 가지 장점이 있습니다.
첫째, 컴파일 타임 안전성. User를 만들 때는 반드시 userId가 필요하고, Anonymous를 만들 때는 반드시 anonymousId가 필요합니다. 잘못된 상태를 만들 수 없습니다.
둘째, exhaustive when 처리. when 표현식에서 모든 케이스를 처리하지 않으면 컴파일 에러가 발생합니다. Unknown 케이스를 빠뜨리는 실수를 컴파일러가 잡아줍니다.
val alreadyViewed = when (viewerId) {
is ViewerId.User -> blogPostViewRepository.existsByPostIdAndUserId(id, viewerId.userId)
is ViewerId.Anonymous -> blogPostViewRepository.existsByPostIdAndAnonymousId(id, viewerId.anonymousId)
ViewerId.Unknown -> return post // Unknown이면 카운트 없이 즉시 반환
}
셋째, 스마트 캐스트. is ViewerId.User 체크 이후 블록에서 viewerId.userId에 바로 접근할 수 있습니다. 명시적 캐스팅이 필요 없습니다.
Unknown 케이스 — 크롤러 방어#
Unknown은 Authorization 헤더도 없고 Anonymous ID도 전송하지 않은 요청입니다. 구글봇, 네이버봇 같은 검색 크롤러나, curl로 직접 API를 호출하는 경우가 해당됩니다.
이런 요청에 조회수를 올려줄 이유가 없습니다. ViewerId.Unknown이면 return post로 조기 반환해서 카운트를 스킵합니다.
4. DB 설계 — Partial UNIQUE INDEX의 진짜 이유#
중복 방지의 핵심은 DB 레벨에서 같은 (post_id, user_id) 또는 (post_id, anonymous_id) 조합이 두 번 저장되지 않도록 하는 것입니다.
CREATE TABLE blog_post_views (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL,
user_id BIGINT,
anonymous_id VARCHAR(64),
viewed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_viewer CHECK (
(user_id IS NOT NULL AND anonymous_id IS NULL) OR
(user_id IS NULL AND anonymous_id IS NOT NULL)
)
);
chk_viewer CHECK 제약은 한 레코드에 user_id와 anonymous_id 둘 다 값이 있거나 둘 다 NULL인 상황을 막습니다. 정확히 하나만 존재해야 합니다.
왜 일반 UNIQUE INDEX가 아닌 Partial INDEX인가#
여기서 중요한 포인트가 있습니다. 처음 설계할 때 이런 인덱스를 생각했습니다.
-- 안 좋은 예시 - 일반 UNIQUE INDEX
CREATE UNIQUE INDEX uq_blog_post_views_user ON blog_post_views (post_id, user_id);
CREATE UNIQUE INDEX uq_blog_post_views_anon ON blog_post_views (post_id, anonymous_id);
이게 왜 안 될까요? SQL 표준에서 NULL은 NULL과 같지 않습니다. NULL != NULL입니다. 따라서 일반 UNIQUE INDEX에서 NULL을 포함한 값은 중복으로 취급되지 않습니다.
실험해보겠습니다. 아래 쿼리를 실행하면 어떻게 될까요?
-- post_id=1, user_id=NULL인 레코드 두 개 삽입 시도
INSERT INTO blog_post_views (post_id, user_id, anonymous_id) VALUES (1, NULL, 'hash_abc');
INSERT INTO blog_post_views (post_id, user_id, anonymous_id) VALUES (1, NULL, 'hash_abc');
일반 UNIQUE INDEX라면 두 번째 INSERT가 UNIQUE 위반으로 실패해야 합니다. 하지만 PostgreSQL에서는 (post_id, user_id) 인덱스의 user_id가 NULL인 경우, 두 NULL은 서로 다른 값으로 취급되어 두 레코드가 모두 삽입됩니다. anonymous_id에 같은 해시값이 있더라도 user_id 인덱스는 이를 중복으로 잡지 못합니다.
Partial UNIQUE INDEX는 이 문제를 해결합니다.
-- Partial UNIQUE INDEX
CREATE UNIQUE INDEX uq_blog_post_views_user
ON blog_post_views (post_id, user_id)
WHERE user_id IS NOT NULL;
CREATE UNIQUE INDEX uq_blog_post_views_anon
ON blog_post_views (post_id, anonymous_id)
WHERE anonymous_id IS NOT NULL;
WHERE user_id IS NOT NULL 조건을 추가하면, 이 인덱스는 user_id가 있는 레코드에만 적용됩니다. NULL 레코드는 인덱스 대상에서 제외되므로 NULL의 비교 동작에 영향받지 않습니다. user_id가 있는 레코드끼리만 (post_id, user_id) 중복 체크를 합니다.
마찬가지로 uq_blog_post_views_anon은 anonymous_id가 있는 레코드끼리만 중복 체크합니다.
이 설계 덕분에 로그인 사용자와 비로그인 사용자가 완전히 독립적으로 중복 방지됩니다.
5. Race Condition — TOCTOU 문제와 해결책#
중복 체크 로직을 보면 이런 패턴입니다.
val alreadyViewed = blogPostViewRepository.existsByPostIdAndUserId(id, userId)
if (!alreadyViewed) {
blogPostViewRepository.save(BlogPostView(...))
}
이 패턴에는 고전적인 TOCTOU(Time-Of-Check-To-Time-Of-Use) Race Condition이 존재합니다.
TOCTOU가 무엇인가#
두 요청이 거의 동시에 들어온다고 가정합니다.
요청 A: existsByPostIdAndUserId() → false (아직 없음)
요청 B: existsByPostIdAndUserId() → false (아직 없음)
요청 A: save(BlogPostView) → 성공
요청 B: save(BlogPostView) → UNIQUE 위반! (A가 방금 삽입했음)
A와 B가 거의 동시에 "없다"고 확인했지만, A가 먼저 삽입해버리면 B는 중복 삽입을 시도하게 됩니다.
왜 Optimistic Locking을 쓰지 않았나#
Race Condition을 해결하는 방법으로 Optimistic Locking이 있습니다. JPA의 @Version 어노테이션을 사용해서 수정 시 버전을 체크하는 방식입니다. 하지만 이 케이스에는 적합하지 않습니다.
Optimistic Locking은 "같은 레코드를 여러 명이 동시에 수정할 때" 충돌을 감지합니다. 저희 케이스는 "새 레코드를 동시에 삽입하는 것"을 막는 문제입니다. Optimistic Locking으로는 INSERT 중복을 방지할 수 없습니다.
Pessimistic Locking(SELECT FOR UPDATE) 도 고려했습니다. 하지만 이 방식은 조회 쿼리마다 락을 걸어야 하므로 성능 오버헤드가 큽니다. 블로그 포스트 조회는 읽기 작업이 압도적으로 많기 때문에 모든 조회에 락을 걸면 병목이 생깁니다.
처음에는 DataIntegrityViolationException을 catch하는 방식을 고려했습니다. Spring Data JPA에서 DB 제약 위반(UNIQUE, NOT NULL, CHECK 등)이 발생하면 이 예외가 던져지므로, try-catch로 잡으면 될 것 같았습니다.
// ⚠️ 이 방식은 Spring + Hibernate에서 예상대로 동작하지 않습니다
if (!alreadyViewed) {
try {
blogPostViewRepository.save(BlogPostView(...))
post.viewCount += 1
blogPostRepository.save(post)
} catch (e: DataIntegrityViolationException) {
// 잡힐 것 같지만 실제로는 잡히지 않습니다
}
}
그런데 이 방식에는 치명적인 함정이 있습니다. Spring Data JPA + Hibernate는 save() 호출 시 SQL을 즉시 실행하지 않습니다. 기본 FlushMode.AUTO 설정에서 실제 INSERT SQL은 트랜잭션이 커밋될 때(메서드 반환 이후) 실행됩니다. 즉, DataIntegrityViolationException이 try-catch 블록 밖에서 던져져 catch에 잡히지 않습니다.
더 나아가, Hibernate는 UNIQUE 제약 위반이 발생하면 Session을 롤백 전용(rollback-only) 상태로 표시합니다. 예외를 catch해도 트랜잭션은 결국 롤백되어 500 에러가 클라이언트에 전달됩니다.
저희가 선택한 방법은 PostgreSQL의 ON CONFLICT DO NOTHING을 사용해 DB 레벨에서 원자적으로 처리하는 것입니다.
// Repository
@Modifying
@Query(
value = """
INSERT INTO blog_post_views (post_id, user_id, anonymous_id, viewed_at)
VALUES (:postId, :userId, :anonymousId, NOW())
ON CONFLICT DO NOTHING
""",
nativeQuery = true
)
fun insertViewIfNotExists(
@Param("postId") postId: Long,
@Param("userId") userId: Long?,
@Param("anonymousId") anonymousId: String?
): Int
// Service
@Transactional
fun getBlogPostAndIncrementView(id: Long, viewerId: ViewerId): BlogPost {
val post = getBlogPost(id)
val alreadyViewed = when (viewerId) {
is ViewerId.User -> blogPostViewRepository.existsByPostIdAndUserId(id, viewerId.userId)
is ViewerId.Anonymous -> blogPostViewRepository.existsByPostIdAndAnonymousId(id, viewerId.anonymousId)
ViewerId.Unknown -> return post
}
if (!alreadyViewed) {
val inserted = blogPostViewRepository.insertViewIfNotExists(
postId = id,
userId = (viewerId as? ViewerId.User)?.userId,
anonymousId = (viewerId as? ViewerId.Anonymous)?.anonymousId,
)
if (inserted > 0) {
post.viewCount += 1
post.recalculatePopularityScore()
blogPostRepository.save(post)
}
}
return post
}
ON CONFLICT DO NOTHING은 UNIQUE 제약 위반이 발생해도 에러를 던지지 않고 해당 INSERT를 조용히 스킵합니다. 반환값은 영향받은 행(affected rows) 수이므로, 실제로 삽입이 되었는지 확인할 수 있습니다.
이 접근의 장점은 원자적(atomic) 이라는 것입니다. exists() 체크에서 false를 반환했더라도 insertViewIfNotExists()가 0을 반환하면(동시 요청이 먼저 삽입한 경우) viewCount를 증가시키지 않습니다. 예외 처리 없이 깔끔하게 TOCTOU를 방어할 수 있습니다.
6. Next.js에서 조회수를 언제 올릴 것인가 — SSR vs CSR 결정#
Next.js App Router를 사용하면 기본적으로 서버 컴포넌트(Server Component)에서 렌더링이 일어납니다. 처음에는 이런 구조를 생각했습니다.
// 잘못된 접근 - SSR에서 조회수 증가
// app/blog/posts/[id]/page.tsx (Server Component)
export default async function BlogPostPage({ params }) {
const post = await getBlogPostAndIncrementView(params.id);
return <BlogPostContent post={post} />;
}
이 방식에는 여러 문제가 있습니다.
SSR에서 조회수를 올리면 안 되는 이유#
첫째, localStorage에 접근할 수 없습니다. 서버에는 브라우저의 localStorage가 없습니다. 익명 사용자 ID를 읽어올 수단이 없으므로, 모든 비로그인 요청이 Unknown으로 처리되어 카운트가 스킵됩니다.
둘째, Next.js는 페이지를 정적으로 캐시합니다. App Router에서 서버 컴포넌트는 기본적으로 캐시됩니다. 같은 URL에 두 번 요청이 와도 서버 함수가 두 번 실행되지 않을 수 있습니다. 조회수 증가 로직이 캐시에 묻혀버립니다.
셋째, 크롤러 요청도 함께 카운트됩니다. 구글봇, 네이버봇이 페이지를 렌더링 요청할 때도 SSR이 실행되므로, 크롤러 방어 로직을 별도로 구현하지 않으면 봇 트래픽도 조회수에 포함됩니다.
CSR 분리 — PostViewTracker 컴포넌트#
해결책은 조회수 트래킹을 클라이언트 사이드로 완전히 분리하는 것입니다.
// app/blog/posts/[id]/page.tsx (Server Component)
export default async function BlogPostPage({ params }) {
const post = await getBlogPost(params.id); // 조회수 증가 없이 콘텐츠만 가져옴
return (
<>
<BlogPostContent post={post} />
<PostViewTracker postId={post.id} /> {/* CSR 트래커 */}
</>
);
}
// components/PostViewTracker.tsx (Client Component)
"use client";
import { useEffect } from "react";
import { getOrCreateAnonymousId } from "@/lib/anonymous-id";
import { recordBlogPostView } from "@/api/blog";
export default function PostViewTracker({ postId }: { postId: number }) {
useEffect(() => {
const track = async () => {
const anonymousId = await getOrCreateAnonymousId();
await recordBlogPostView(postId, anonymousId);
};
track();
}, [postId]);
return null; // UI 없음, 트래킹만 담당
}
이 분리의 핵심 포인트를 설명합니다.
"use client" 지시어: 이 컴포넌트는 클라이언트 사이드에서만 실행됩니다. localStorage 접근이 안전합니다.
useEffect([postId]): 컴포넌트가 마운트된 후 딱 한 번 실행됩니다. postId가 바뀌지 않는 한 재실행되지 않으므로, 동일 페이지에서 리렌더링이 발생해도 중복 호출이 생기지 않습니다.
return null: 이 컴포넌트는 UI를 렌더링하지 않습니다. 오직 side effect(조회수 트래킹)만 담당합니다. 관심사 분리(Separation of Concerns)를 철저히 적용한 결과입니다.
getOrCreateAnonymousId(): 로그인 상태 여부는 API 요청의 Authorization 헤더를 통해 백엔드에서 판단합니다. 프론트엔드는 익명 ID만 전달하면 됩니다. 로그인 사용자라면 백엔드에서 토큰을 파싱해 userId를 사용하고, 비로그인 사용자라면 전달된 익명 ID를 사용합니다.
SSR로 콘텐츠를 먼저 렌더링하는 이점#
이 구조에서 콘텐츠는 SSR로 빠르게 렌더링되고, 조회수 트래킹은 CSR로 비동기 처리됩니다. 사용자 입장에서는 페이지가 즉시 보이고, 조회 기록은 백그라운드에서 처리됩니다. 트래킹 API 호출이 실패해도 페이지 렌더링에 영향을 주지 않습니다.
7. 조회수 UI — 작은 디테일에 담긴 고민#
조회수를 표시하는 UI도 작은 결정들이 모여 있습니다.
export function formatViewCount(count: number): string {
if (count < 1000) return String(count);
if (count < 10000) return `${Math.floor(count / 100) / 10}k`;
return `${Math.floor(count / 1000)}k`;
}
999까지는 그대로 표시하고, 1000부터는 1.2k, 10000부터는 12k 형식을 씁니다. 숫자가 커질수록 소수점이 의미 없어지기 때문입니다. 12,435보다 12k가 빠르게 읽힙니다.
import { LuEye } from "react-icons/lu";
<span aria-label={`조회수 ${viewCount}회`}>
<LuEye aria-hidden="true" />
{formatViewCount(viewCount)}
</span>
aria-label로 스크린 리더 사용자를 위한 접근성을 챙겼습니다. 아이콘은 aria-hidden="true"로 처리해서 스크린 리더가 아이콘을 읽지 않도록 했습니다. aria-label로 이미 의미를 전달하고 있기 때문입니다.
8. 알려진 트레이드오프 — 비로그인에서 로그인으로 전환 시 이중 카운트#
이 시스템에는 한 가지 알려진 허점이 있습니다. 비로그인 상태에서 글을 보고, 이후 로그인하면 다시 한 번 카운트됩니다.
비로그인 상태에서는 anonymous_id로, 로그인 상태에서는 user_id로 기록되기 때문입니다. DB 레벨에서 이 둘을 연결할 방법이 없습니다. 한 사람이 두 번 카운트될 수 있습니다.
이 문제를 해결하는 방법이 없는 건 아닙니다. 로그인 시 localStorage의 익명 ID와 userId를 매핑하는 테이블을 만들 수 있습니다. 하지만 이 구현은 상당히 복잡해집니다.
- 로그인 흐름에 매핑 로직 추가
- 매핑 테이블 관리
- 이미 기록된 익명 뷰를 어떻게 처리할지 결정
이 복잡성이 얻는 이익(극히 드문 케이스의 이중 카운트 방지)보다 크다고 판단했습니다. 실제로 같은 글을 비로그인과 로그인 상태에서 모두 보는 경우는 매우 드뭅니다. 완벽하지 않아도 충분히 나은 시스템을 선택했습니다.
9. 구현 결과와 검증#
시스템을 배포하고 GA4 데이터를 다시 확인했습니다. 2주 후 Post #5의 뷰/유저 비율은 8.0 → 1.6으로 떨어졌습니다. 다른 포스트들과 비슷한 수준으로 정상화되었습니다.
더 중요한 것은 팀원들이 개발 중 글을 반복 확인해도 조회수가 올라가지 않는다는 점입니다. 이제 조회수가 실제 독자 수를 더 정확하게 반영합니다.
구현하면서 배운 것들을 정리합니다.
DB 제약은 최후의 방어선#
애플리케이션 레벨에서 아무리 체크해도 Race Condition은 피하기 어렵습니다. DB의 UNIQUE INDEX 같은 하드 제약이 궁극적인 보장을 줍니다.
NULL은 생각보다 까다롭다#
SQL의 NULL 동작은 직관적이지 않습니다. UNIQUE INDEX와 NULL의 상호작용을 이해하지 못했다면 Partial INDEX 없이 일반 UNIQUE INDEX를 썼을 것입니다. 그랬다면 비로그인 중복 방지가 작동하지 않았을 겁니다.
sealed class는 코드를 자기 문서화한다#
ViewerId 타입을 보는 것만으로 이 시스템이 세 가지 케이스를 처리한다는 것을 알 수 있습니다. 주석 없이도 의도가 명확합니다.
SSR/CSR 경계를 명확히 하면 버그가 줄어든다#
Next.js에서 서버/클라이언트 경계를 흐릿하게 두면 "왜 localStorage가 없냐"는 런타임 에러를 자주 만납니다. 트래킹 로직을 "use client" 컴포넌트로 격리하는 것 자체가 버그 방지입니다.
마치며 — 완벽하지 않아도 충분히 나은 시스템을#
조회수 중복 방지라는 문제는 겉으로 보기엔 단순합니다. "같은 사람이 두 번 보면 한 번으로 세면 되는 거 아냐?"
하지만 실제 구현에는 비로그인 사용자 식별, SQL NULL의 동작, Race Condition, SSR의 특성, 개인정보 보호 등 여러 레이어가 얽혀 있습니다. 각 레이어에서 옳은 결정을 내리는 것이 결국 시스템 전체의 신뢰성을 만듭니다.
저희가 선택한 방법이 완벽하지는 않습니다. 비로그인→로그인 전환 시 이중 카운트는 남아 있습니다. 시크릿 모드로 우회하는 것도 가능합니다. 하지만 이 시스템은 기존보다 압도적으로 정확한 데이터를 제공합니다. 그리고 그걸로 충분합니다.
엔지니어링은 완벽한 해결책을 찾는 것이 아니라, 현재의 제약 안에서 충분히 나은 해결책을 찾는 것이라고 생각합니다.
☕️ 커피챗#
비슷한 고민을 하고 계시거나 같이 실험해보고 싶은 분은 커피챗 하고 싶습니다 ☕️ 📩 ksy90101@gmail.com
함께 읽으면 좋은 글
댓글
0의견을 남겨보세요. 로그인하면 닉네임이 자동으로 입력됩니다.
댓글을 불러오는 중...