logoStephen's 기술블로그

포스트 검색

제목, 태그로 포스트를 검색해보세요

세션없이 방문자수 구현하기

세션없이 방문자수 구현하기
NextJS
성훈 김
2025년 8월 23일
목차

개요

먼저 제가 운영하고 있는 블로그 사이트는 notion API를 활용한 사이트에요. notion에서는 페이지에 대한 페이지뷰나 방문자수를 따로 제공하지 않았기 때문에, 제가 직접 구현할 필요가 있었죠.
 
보통 회원가입 로직이 있다면 page view라던지 접속한 사용자의 세션으로 중복없이 처리할 수 있었겠지만, 회원로직이 없는 사이트에서는 어떻게해서 방문자수나 페이지를 구현해야 했는지 알아보도록 할게요.
 

The First Idea : IP와 UserAgent의 조합

일단 제일 처음 떠오른 아이디어는 HTTP를 통한 페이지 요청에서 얻게되는 IP정보와 UserAgent를 구별하는 방법이었어요.
 
Application/post-usecase.adapter.ts
JavaScript
    addMainPageView: async (request: Promise<Headers>): Promise<void> => {
      const headers = await request;

      // 1. 사용자 정보 추출
      const ipHeader = headers.get('x-forwarded-for') ?? '';
      const ip = ipHeader.split(',')[0].trim() || 'unknown';
      const userAgent = headers.get('user-agent') ?? 'unknown';

      // 1-1. 크롤링 봇 검증
      const isCrawlingBot = crawlingBotCheck(userAgent);
      if (isCrawlingBot) return;

      // 1-2. IP 해시 생성
      const ipHash = await hashIp(ip);
      const todayKST = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(
        new Date()
      );

      const visitor = {
        ipHash: ipHash.toString(),
        todayKST: todayKST,
        pathname: '/',
        userAgent: userAgent,
      };
      
   // 이하 코드 생략
 
사용자가 각 페이지를 요청할 떄, 해당 코드는 서버에서 SSR로 실행되면서 동작하게 되는 코드에요. 그래서 header에서 매 요청에 담긴 ipuseAgent정보를 알아낼 수 있었죠. 여기서 주의해야 했던 점은 다음과 같아요.
  • 개인정보 노출을 최소화하기 위한 ip 해쉬화 저장
  • userAgent 검증을 통해서 크롤링 봇 검증
 

크롤링 봇 검증 로직

먼저 IP검증을 하기 전에 크롤링 봇을 먼저 검증해야 했어요. 해쉬 알고리즘은 비용을 고려해야되는 알고리즘이라고 생각 했기 때문이에요. 만약 크롤링 봇 로직이 IP검증로직 뒤에 있었다면, 크롤링 봇은 방문자 수로 카운트를 하지 않음에도 불구하고 IP 알고리즘 계산을 해야하는 불필요한 일을 하면 안되니까요.
 
그리고 크롤링 봇 검증을 안하고 방문자수를 부풀리고 싶은 마음이 있을 수도 있겠지만, 저는 거짓된 정보에 과시하거나 자랑같은거 하고 싶지 않았어요. 그래야 진짜 방문자 수가 늘어날 때 기쁠것 같았어요.
 
크롤링 봇 검증 로직
JavaScript
export const crawlingBotCheck = (userAgent: string) => {
  if (!userAgent) return false;
  const ua = userAgent.toLowerCase();
  
  const botRegex =
    /\b(?:googlebot|bingbot|yandexbot|duckduckbot|baiduspider|ahrefsbot|semrushbot|mj12bot|baiduspider)\b/;
  return botRegex.test(ua);
};
 
먼저 정규식으로 잘 알려진 봇들을 위주로 검증하는 로직을 만들고, 향후 문제가 될만한 방문자 카운팅은 향후에 수정하고 싶었어요. 그리고 바로 배포하자마자 방문자 수가 증가해서 기뻐했었는데 데이터 베이스에 기록된 useAgent를 보니 수상한 점이 있었어요.
 
배포 할 때마다 vercel에서 스크린샷을 찍어 간다는 것을 알게 되었습니다.
배포 할 때마다 vercel에서 스크린샷을 찍어 간다는 것을 알게 되었습니다.
 
그건 vercel이름이 중복되어서 기록이 되어있었기 때문인데요. Vercel로 배포하면 항상 스크린 샷을 찍어간다는 것을 알게되었어요. 그래서 vercel도 봇 검증에 추가하게 되었어요. 그리고 그 다음 날에는 또 다른 일이 있었죠.
 
notion image
 
그 다음 날에는 조회수가 90이나 증가했길래, 우와 이렇게 인플루언서가 되는건가하고 꿈을 잠깐 꾸었어요. 실은 google 봇이 제가 생각지 못한 이름으로 search console 부서에서 url 테스트를 한 것 같았어요. 그리고 다른 따라서 이 이름도 봇 검증에 추가 해야 했었어요.
 
완성된 크롤링 봇 검증 로직
JavaScript
export const crawlingBotCheck = (userAgent: string) => {
  if (!userAgent) return false;
  const ua = userAgent.toLowerCase();

  if (ua.startsWith('vercel-')) return true;
  if (ua.includes('Mediapartners-Google')) return true;
  
  const botRegex =
    /\b(?:googlebot|bingbot|yandexbot|duckduckbot|baiduspider|ahrefsbot|semrushbot|mj12bot|baiduspider)\b/;
  return botRegex.test(ua);
};
 
방문자 수가 늘어서 잠깐 좋아했지만, 역시 봇 검증을 하니 방문자 수가 뚝 떨어지더군요 흑흑 ㅠ
 

IP 로직 : 개인정보 저장을 최소화하기 위한 노력

크롤링 봇을 검증을 통과하면 그 때 IP 로직을 실행하도록 했어요. 처음에 바로 ip를 해쉬화해야겠다고 생각했어요. 개인정보 보호법에서 요구한다고 할 수는 없지만, 개인정보 보호의 원칙중에 정보 최소화 수집에 따른 결정이었어요.
 
여기서 ip를 해쉬화 해서 저장할 때는 여러가지 요소를 고려해야했어요. 예를 들어 해당 함수가 브라우저와 edge서버와 node.js서버에서 정상적으로 작동하는 지중요했어요. 사실 이 crypto함수를 다른 라이브러리를 사용하다가 로컬에서는 잘되는 데 배포하고 나서 잘 안되었던 경우가 꽤 많았었어요. 그래서 이 부분은 꼭 체크하시길 권장해요. 그리고 이 해쉬함수의 로직이 비용이 많이 드는 로직은 아닌지도 고려 대상이었죠. 그래서 구현하게 된 코드는 다음과 같아요.
 
IP 해시 로직
JavaScript
export async function hashIp(ip: string): Promise<string> {
  const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
  const ipHash = Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
  return ipHash;
}
 
따라서 Web Cryptography API 에서 제공하는 crypto 객체는 어느 환경에서도 잘 동작하는 내장 객체에요. 프론트엔드에서는 실행환경이 많이 달라지기 때문에 이러한 부분을 챙기는 것은 매우 중요해요.
 
그리고 암호화 알고리즘을 선택할 때 고려해야했던 것은 단방향 알고리즘인지, 그리고 암호화 해시를 했을때 항상 같은 값을 도출하는 지가 중요했어요. 그래서 ‘SHA-256’를 선택하게 되었는데, 이 알고리즘은 광법위하게 사용되고, 그리고 ip같은 짧은 문자열에서는 큰 비용차이를 보이지 않기 떄문에 선택을 하게 되었어요.
 

IP와 UserAgent 검증만으로 겪게되는 문제점

현재 루트 페이지는 매 요청마다 Server-Side Rendering을 하게 되고, 각 디테일 페이지는 만료시간을 가진
SSG(ISR)로 서빙을 하게 됩니다. 코드에서 아아피를 검증하기 때문에, 방문자가 다른 페이지를 방문하면 page-view는 올라가지만 총 방문자수는 카운팅되지 못하도록 했었죠.
 
하지만, 제가 다른 곳으로 이동해서 접속하게 되면 아이피가 변경이 되어서 저를 새로운 방문자로 인식하여 또 카운팅이 되어 버리는 문제점을 발견하게 되었어요.
 
즉, stateless 웹에서 방문자 수를 구현하더라도 쿠키까지 사용해야된다는 것이었죠.
 

쿠키검증 추가

쿠키를 검증하면서 제가 하고싶었던 것은, 쿠키가 없으면 한국시간 자정까지의 만료기간을 가진 쿠키를 발급하고 싶었어요. 즉, 한 사용자가 장소를 옮겨서 다시 제 사이트에 방문하더라도, 다시 재 카운팅이 되지 않게 했어야 하니까요.
 
즉, 한 사용자가 제 사이트에 방문하면 총 1의 방문자수가 올라가고, 해당 방문자가 여러 페이지를 접속하게 되면 각 페이지 뷰는 증가시키고 싶었어요. 따라서 쿠키 검증은 페이지뷰를 추가하고 나서 방문자 수를 증가시키기 전에 쿠키검증을 해서, 여러 페이지의 뷰카운트는 증가 시키되 (하루 한번) 총 방문자수에는 영향이 없도록 해야했죠.
 
구현된 코드와 순서는 아래와 같아요.
 
JavaScript
addMainPageView: async (request: Promise<Headers>): Promise<void> => {
      const headers = await request;

      // 1. 사용자 정보 추출
      const ipHeader = headers.get('x-forwarded-for') ?? '';
      const ip = ipHeader.split(',')[0].trim() || 'unknown';
      const userAgent = headers.get('user-agent') ?? 'unknown';

      // 1-1. 크롤링 봇 검증
      const isCrawlingBot = crawlingBotCheck(userAgent);
      if (isCrawlingBot) return;

      // 1-2. IP 해시 생성
      const ipHash = await hashIp(ip);
      const todayKST = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(
        new Date()
      );

      const visitor = {
        ipHash: ipHash.toString(),
        todayKST: todayKST,
        pathname: '/',
        userAgent: userAgent,
      };

      await db.transaction(async (tx) => {
        // 2. 사용자 정보 오늘 날짜 조회 - 없으면 생성
        const foundVisitor = await visitorInfoRepo.getVisitorInfoOrCreate(visitor, tx);
        if (!foundVisitor.success) {
	        // 오늘 방문 기록에 해당페이지가 있으면 바로 리턴
          if (foundVisitor.statusCode && foundVisitor.statusCode === 400) return;
          throw foundVisitor.error;
        }

        const updatedVisitor = await visitorInfoRepo.updateVisitorPathname(
          foundVisitor.data,
          '/',
          todayKST,
          tx
        );
        if (!updatedVisitor.success) throw updatedVisitor.error;

        // 3. 오늘 날짜 현재 페이지 뷰 조회 - 없으면 생성
        const pageView = await pageViewRepo.getPageViewOrCreate('main', '/', tx);
        if (!pageView.success) throw pageView.error;

        // 4. 오늘 날짜 방문자 수 증가
        const updateResult = await pageViewRepo.updatePageView(pageView.data, tx);
        if (!updateResult.success) throw updateResult.error;

				// 5. 쿠키 검증
        const isNewVisitor = await checkCookies();
        if (!isNewVisitor) return;

        // 6. VisitStats 업데이트
        const siteMetric = await siteMetricRepo.updateSiteMetric(todayKST, foundVisitor.data, tx);
        if (!siteMetric.success) throw siteMetric.error;
        return;
      });
    }
 
여기서 쿠키를 또 사용해서 검증한다는 것은 또 다른 챌린지를 만들어 내게 되었어요. 그 이유는 서버컴포넌트에서 사용 가능한 headers() 함수는 read-only이기 때문에 쿠키를 발급할 수 없었기 때문이에요.
 
공식문서 - 서버 컴포넌트에서 headers는 read-only라서 set / delete를 할 수 없습니다.
공식문서 - 서버 컴포넌트에서 headers는 read-only라서 set / delete를 할 수 없습니다.
 
그래서 Next.js에서 제공해주는 cookies() 함수를 사용할려면 라우트 핸들러 또는 서버 액션에서 사용되어야 해요. 서버액션을 사용할려면 비동기 함수가 되어야 하고, 비동기 함수로 적용할려면 현재 저의 프로젝트 구조가 변경되는 부분이 많았어요. .
 
app/blog/[id]/page.tsx
페이지 뷰 추가가 필요한 곳에 클라이언트 컴포넌트 추가
페이지 뷰 추가가 필요한 곳에 클라이언트 컴포넌트 추가
 
라우트 핸들러에 페이지 뷰를 요청할 클라이언트 컴포넌트를 재사용 가능하게 만들었어요. 변경을 최소화하면서 적용하기 위해서는 이렇게 방향이 제일 간편했어요. 쿠키검증에 사용되는 코드는 다음과 같아요. 한국 자정시간과 현재시간을 비교해 남은 시간을 expires를 설정하고 maxAge도 같이 설정해주었어요.
 
사용되는 쿠키 유틸 함수
JavaScript
import { dateToKoreaDateString } from '@/shared/utils/format-date';
import { cookies } from 'next/headers';

// 한국 자정시간과 maxAge를 반환하는 유틸 함수
export const getKstMidnightExpiry = (): { expires: Date; maxAge: number } => {
  const now = new Date();
  // KST 기준 현재 시각 얻기 (구성요소 추출용)
  const kstNow = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
  const year = kstNow.getFullYear();
  const month = kstNow.getMonth();
  const date = kstNow.getDate();

  // KST의 다음 자정(00:00)을 UTC ms로 계산: Date.UTC(...)는 UTC 기준이므로 9시간을 빼준다.
  const nextMidnightUtcMs = Date.UTC(year, month, date + 1, 0, 0, 0) - 9 * 60 * 60 * 1000;
  const expires = new Date(nextMidnightUtcMs);

  // 만료까지 남은 초 계산, 최대 86400초(1일)
  let maxAge = Math.floor((expires.getTime() - Date.now()) / 1000);
  if (maxAge > 86400) maxAge = 86400;
  if (maxAge < 0) maxAge = 0;

  return { expires, maxAge };
};

// Application 레이어에서 사용되는 쿠키 검증 함수
export const checkCookies = async () => {
  const cookieStore = await cookies();
  const cookie = cookieStore.get('visitor-cookie');
  const todayKST = dateToKoreaDateString(new Date());
  let isNewVisitor = true;

  if (!cookie || cookie.value !== todayKST) {
    cookieStore.set({
      name: 'visitor-cookie',
      value: todayKST,
      expires: getKstMidnightExpiry().expires,
      maxAge: getKstMidnightExpiry().maxAge,
      httpOnly: true,
      secure: true,
      path: '/',
    });
    isNewVisitor = true;
    return isNewVisitor;
  }

  if (cookie && cookie.value === todayKST) {
    isNewVisitor = false;
    return isNewVisitor;
  }

  return isNewVisitor;
};
 

결론

결론적으로 stateless 웹에서 방문자 수를 구현할 때 쿠키 / IP / UserAgent 통한 검증을 해야, 진짜 의미있는 방문자 수가 구현이 된다는 것을 알게된 구현과정이었어요. 이제 뻥튀기 되는 숫자가 아니라서, 방문자 수가 올라갈 때마다 진짜 기쁘더라구요. 🙂 많이 봐주셔서 감사하다는 말씀 드립니다.
 
notion image
 
다음에 또 여러 구현 아이디어로 찾아뵐게요 :)