테스트 페이지
Jan 24, 2025
1. 서버 사이드에서 로깅하는 방법!!
- 페이지 방문 후 서버에 요청이 들어오는 시점에 blog_id, post_id 정보를 담아 빈 log 생성 후 페이지 컴포넌트 return. page 의 prop 으로 blog, post 데이터와 함께 생성한 log_id 값도 같이 전달
 
...
  const { traffic_type } = props.searchParams;
  const isInternal = traffic_type === "internal";
  let logId = null;
  if (!isInternal) {
    const { data, error: logError } = await supabase
      .from("logs")
      .insert({
        blog_id: postProps.blog.id,
        post_id: postProps.post.id,
      })
      .select("id")
      .single();
    logId = data?.id ?? null;
  }
  const serverContent = (
    <div
      className={clsx(
        "px-5 w-full",
        postProps.post.content_type === "tiptap" && "tiptap"
      )}
      dangerouslySetInnerHTML={{ __html: contentHtml ?? "" }}
    />
  );
  return (
    <>
      {isTeamPlan && postProps.post.post_custom_scripts?.json_ld_script && (
        <script
          id="post-json-ld"
          type="application/ld+json"
          dangerouslySetInnerHTML={{
            __html: postProps.post.post_custom_scripts?.json_ld_script ?? "",
          }}
        />
      )}
      {/* body start scripts */}
      {isTeamPlan && postProps.post.post_custom_scripts?.body_start_script && (
        <>
          {parseScripts(postProps.post.post_custom_scripts.body_start_script)}
        </>
      )}
      <PostPage {...postProps} logId={logId} children={serverContent} />
      {/* body end scripts */}
      {isTeamPlan && postProps.post.post_custom_scripts?.body_end_script && (
        
      )}
    </>
  );
...- client side component 가 mount 시점에서 넘겨 받은 log_id 를 통해 client 정보를 update.
 
...
useEffect(() => {
    if (!logId) return;
    const sessionId = getOrCreateSessionId();
    let userId = getCookie("_inblog_user");
    if (!userId) {
      userId = generateUserId();
      setCookie("_inblog_user", userId);
    }
    const updateLog = async () => {
      const { data, error } = await supabase
        .from("logs")
        .update({
          device: isMobile()
            ? "mobile"
            : isMobileTablet()
              ? "tablet"
              : "desktop",
          referrer: window.document.referrer
            ? new URL(window.document.referrer).hostname
            : "direct",
          full_referrer: window.document.referrer,
          is_routing_back: false,
          session_id: sessionId,
          user_id: userId,
        })
         .eq("id", logId);
    };
    updateLog();
  }, [logId]);
...- 버튼 클릭시 마찬가지로 넘겨받은 log_id 를 통해 해당 log 의 is_click 을 true 로 업데이트
 
const { data, error } = await supabase
        .from("logs")
        .update({ is_click: true })
        .eq("id", logId);- 서버에 요청이 들어올 때 마다 어떤 블로그 어떤 포스트에 요청이 들어왔는지 먼저 기록하고, 기록한 값을 페이지에 서명해서 클라이언트에 전달해주는 느낌
 
- 서버에 해당 페이지에 url 로 들어오는 요청을 큰 변수 없이 catch 해서 기록 할 수 있음
 
- logId 값을 서버에서 전달한 상태로 page 를 내려주기 때문에, client-side 렌더링 방식과 달리 뒤로가기 등을 통해 다시 페이지로 돌아가더라도 해당 log 는 특정해서 기록할 수 있음
 
- 뒤로가기 케이스는 한 번 렌더링 된 페이지에 대해서 브라우저가 서버에 다시 url를 호출하지 않기 때문에 자동으로 기록되지 않음
 
중요! 서버 사이드 로깅을 채택하지 못한 이유
Next js <Link/> 를 사용하기 때문에, 실제로 방문하지 않은 페이지에 대하여 Next js 가 preload  하는 과정에서 log 가 serverside 에서 기록됨

방문한 위의 post_id 39981 페이지의 경우 
- 서버에서 post_id 39981 로그 생성 (클라이언트 정보는 빈 상태)
 
- client side 에서 39981 포스트 컴포넌트의 mount 와 동시에 session_id, user_id 등 클라이언트 정보 로그에 업데이트
 
- hydration 이후 view port 내에 있는 author (inblog 팀 프로필), more-articles 의 포스트들 (오른쪽 2개) 에 대해 Next js Link 가 미리 정보를 호출
 
- 서버 사이드에서는 받은 콜에 의해 방문하지도 않은 3 페이지에 대해 빈로그 생성
 

session_id, user_id 가 빈 로그는 실제로 방문하지 않은 페이지라고 판단할 수는 있으나, Link 특성상 불필요한 빈 로그가 너무 많이 생성될 수 있음.
⇒ 배포 1주일 앞둔 시점에서 서버 사이드 로깅 채택하기 어려운 가장 큰 사유
2. 클라이언트 사이드에서 로깅하는 방법
- layout.tsx 상단에 <Script/> logging.js 스크립트 추가 (/public/logging.js)
 - window.addEventListener “popstate” 를 통해 뒤로가기 event 를 구분
 - post, blog 페이지의 post_id, blog_id 값이 서버에서 받아진 시점을 “inblog-log-event” listen 하고 “popstate” 상태와 결합하여 로깅할지 여부를 결정
 - “/api/log-click” , "/api/log-view" api 를 호출하여 supabase 로깅 로직 실행
 - 플로우
 - routing 시 isLogging, isRoutingBack(뒤로가기 여부) 초기화
 - "inblog-log-event" 이벤트를 listen 하여 post, blog 페이지가 서버에서 받은 post_id, blog_id 값을 event detail로 받음
 - "inblog-log-event" 이벤트 catch 이후 50ms 버퍼 타임을 두고, “popstate” 이벤트가 정상적으로 받아졌음을 가정.
 - “popstate” 여부와 event로부터 받은 post, blog id 값, 추가적인 client,user,session 정보를 /api/log-view" api 를 호출하여 supabase 로깅 로직 실행
 - 같은 페이지 상에서 유저가 cta 클릭시, is_click = true 인 "inblog-log-event" 이벤트 catch, “/api/log-click” 을 통해 클릭 이벤트 기록
 
(function () {
  const LOG_EVENT_NAME = "inblog-log-event";
  let currentPath = null;
  let isRoutingBack = false;
  let isLogging = false;
  let isInitialized = false;
  window.addEventListener("inblog-log-event", async (e) => {
    if (!isInitialized) return;
    const searchParams = new URLSearchParams(window.location.search);
    const trafficType = searchParams.get("traffic_type");
    if (trafficType === "internal") return;
    if (isLogging) return;
    isLogging = true;
    const isForClick = e.detail.is_click;
    if (isForClick) {
      fetch("/api/log-click", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          session_id: e.detail.session_id,
          post_id: e.detail.post_id,
        }),
      });
    } else {
      // Add a 50ms delay
      await new Promise((resolve) => setTimeout(resolve, 50));
      // Fetch the log view API
      fetch("/api/log-view", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          ...e.detail,
          is_routing_back: isRoutingBack,
        }),
      });
    }
    isLogging = false;
  });
  // Listen for browser back/forward button usage
  window.addEventListener("popstate", () => {
    isRoutingBack = true;
  });
  // Handle route changes (pushState / replaceState)
  function handleRouteChange() {
    const newPath = window.location.pathname;
    // Store the updated current path
    currentPath = newPath;
    isLogging = false;
    isRoutingBack = false;
    logId = null;
  }
  // Wrap native history methods to detect route changes
  function rewriteHistoryMethod(methodName) {
    const original = history[methodName];
    return function (...args) {
      original.apply(this, args);
      handleRouteChange();
    };
  }
  history.pushState = rewriteHistoryMethod("pushState");
  history.replaceState = rewriteHistoryMethod("replaceState");
  // Run once on initial load
  handleRouteChange();
  window.dispatchEvent(new Event("inblog-initial-log-event"));
  isInitialized = true;
})();
스크립트의 역할
- 클라이언트 컴포넌트에 사용되는 useLogView hook
 
logIdRef 와 “inblog-initial-log-event” listener 가 있는 이유
 <Script src={`${INBLOG_URL}/logging.js`} strategy="afterInteractive" />layout.tsx 의 Script code 가 
strategy="afterInteractive" (beforeInteractive 는 hydration 에러 발생) 인데, 가장 첫 페이지 렌딩 시 client component 에서 dispatch 하는 
window.dispatchEvent( new CustomEvent("inblog-log-event", 시점에 script 의 listener 가 init 되지 않음. 스크립트가 실행되고 1회만 
window.dispatchEvent(new Event("inblog-initial-log-event")); 하여 client component 에서 로깅하도록 처리
import { useEffect, useRef, useState } from "react";
import supabase from "@/lib/supabase";
import { useInterval } from "react-use";
import { v4 as uuidv4 } from "uuid";
export type LogType = "home" | "post" | "author" | "category";
export const LOGGING_EVENT_NAME = "inblog-log-event";
export function useLogView({
  blogId,
  postId,
  authorId,
  tagId,
  logType,
}: {
  blogId: number;
  postId?: number;
  authorId?: string;
  tagId?: number;
  logType: LogType;
}) {
  const [isLogging, setIsLogging] = useState(false);
  const logIdRef = useRef<number | null>(null);
  const logPostCTAClickEvent = async () => {
    if (logType !== "post" || !postId) return;
    const userId = getCookie("_inblog_user");
    const sessionId = sessionStorage.getItem("inblog-session-id");
    if (!userId || !sessionId) return;
    if (logIdRef.current) {
      const { data, error } = await supabase
        .from("logs")
        .update({ is_click: true })
        .eq("id", logIdRef.current);
      if (error) console.error("Error logging post CTA click:", error);
    } else {
      window.dispatchEvent(
        new CustomEvent("inblog-log-event", {
          detail: {
            is_click: true,
            session_id: sessionId,
            post_id: postId,
          },
        })
      );
    }
  };
  useEffect(() => {
    const handleLogEvent = async () => {
      if (
        !TEST_BLOG_IDS.includes(blogId) &&
        process.env.NEXT_PUBLIC_VERCEL_ENV !== "production"
      ) {
        console.log("Skipping logging because we're not in production");
        return;
      }
      if (typeof window === "undefined") return;
      if (isLogging) return;
      setIsLogging(true);
      const sessionId = getOrCreateSessionId();
      let userId = getCookie("_inblog_user");
      if (!userId) {
        userId = generateUserId();
        setCookie("_inblog_user", userId);
      }
      // Insert log into Supabase
      const { data, error } = await supabase
        .from("logs")
        .insert({
          blog_id: blogId,
          post_id: postId ?? null,
          session_id: sessionId,
          user_id: userId,
          created_at: new Date().toISOString(),
          is_click: false,
          device: isMobile()
            ? "mobile"
            : isMobileTablet()
              ? "tablet"
              : "desktop",
          referrer: window.document.referrer
            ? new URL(window.document.referrer).hostname
            : "direct",
          full_referrer: window.document.referrer,
          log_type: logType,
          author_uuid: authorId ?? null,
          tag_id: tagId ?? null,
          is_routing_back: false,
        })
        .select()
        .single();
      if (error) {
        console.error("Error inserting log:", error);
        return;
      }
      logIdRef.current = data.id;
    };
    // Add event listener
    window.addEventListener("inblog-initial-log-event", handleLogEvent);
    return () => {
      window.removeEventListener("inblog-initial-log-event", handleLogEvent);
    };
  }, []);
  useEffect(() => {
    if (typeof window === "undefined") return;
    if (isLogging) return;
    console.log("dispatching event");
    setIsLogging(true);
    let userId = getCookie("_inblog_user");
    if (!userId) {
      userId = generateUserId();
      setCookie("_inblog_user", userId);
    }
    window.dispatchEvent(
      new CustomEvent("inblog-log-event", {
        detail: {
          blogId,
          postId,
          sessionId: getOrCreateSessionId(),
          userId,
          created_at: new Date().toISOString(),
          is_click: false,
          device: isMobile()
            ? "mobile"
            : isMobileTablet()
              ? "tablet"
              : "desktop",
          referrer: window.document.referrer
            ? new URL(window.document.referrer).hostname
            : "direct",
          full_referrer: window.document.referrer,
          log_type: logType,
          author_uuid: authorId ?? null,
          tag_id: tagId ?? null,
        },
      })
    );
  }, [postId, blogId]);
  return {
    logPostCTAClickEvent,
  };
}
function getCookie(name: string): string | null {
  const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
  return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name: string, value: string, days = 365) {
  // Default 365 days
  const date = new Date();
  date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
  const expires = `; expires=${date.toUTCString()}`;
  document.cookie = `${name}=${encodeURIComponent(value)}${expires}; path=/`;
}
// -- Generate a basic random user ID, e.g. for first-time visitors --
function generateUserId() {
  return `_inblog_user_${Math.random().toString(36).substring(2)}`;
}
const isMobile = () => {
  let check = false;
  (function (a) {
    if (
      /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
        a
      ) ||
      /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
        a.substr(0, 4)
      )
    )
      check = true;
  })(window.navigator.userAgent);
  return check;
};
const isMobileTablet = () => {
  let check = false;
  (function (a) {
    if (
      /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(
        a
      ) ||
      /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
        a.substr(0, 4)
      )
    )
      check = true;
  })(window.navigator.userAgent);
  return check;
};
function generateSessionId(): string {
  return uuidv4();
}
function getOrCreateSessionId() {
  let sessionId = sessionStorage.getItem("inblog-session-id");
  if (!sessionId) {
    sessionId = generateSessionId();
    sessionStorage.setItem("inblog-session-id", sessionId);
  }
  return sessionId;
}
- 뒤로 가기 기록과 버튼 클릭 처리 방법
 - 일반적인 정상 routing 과 뒤로가기/앞으로가기 버튼 routing 모두 log 에 기록
 - 뒤로가기/앞으로가기 routing 은 
is_routing_back은 true 로 구분 (page view 기반 pricing 용 count 에서 제외) - A→B 이동 후, 뒤로가기 버튼으로 A 페이지로 돌아간 경우
 - post_id: a, is_routing_back: false, is_click: false
 - post_id: b, is_routing_back: false, is_click: false
 - post_id: a, is_routing_back: true, is_click: false
 - CTA 버튼 클릭 처리 방법
 - CTA 클릭 시 event 로 post_id 와 session_id 전달
 - session_id 와 post_id 기반으로 현재 버튼을 클릭한 log 의 row 구함
 - 해당 log 의 is_routing_back 이 false 인 경우, 정상적인 방문이므로 is_click 을 true 로 update
 - 해당 log 의 is_routing_back 이 true 인 경우, 뒤로가기 방문에서 버튼을 누르는 경우로 페이지 뷰 수 대비 중복 클릭이 발생할 수 잇음
 - 예시 케이스: 홈 → 39981 포스트 → 39773 포스트 → 뒤로가기 버튼 클릭으로 39981 포스트 복귀 → CTA 클릭
 - 홈 화면 방문 로그 생성 (4546145)
 - 39981 포스트 방문 로그 생성 (4546151), 
is_clickfalse /is_routing_backfalse - 39773 포스트 방문 로그 생성 (4546152)
 - 39981 포스트 방문 로그 생성 (4546157), 
is_clickfalse /is_routing_backtrue - CTA 버튼 클릭시, 로그의 투명성을 최대화 하기 위해 우선 뒤로가기로 생성된 4546157 로그의 
is_clicktrue 로 수정 - 같은 세션의 같은 post_id 방문한 log 중 
is_clickfalse /is_routing_backfalse 인 가장 최신 로그를 찾고, - CTR 계산을 위해 click 이벤트의 수가 
is_routing_back이 true 가 아닌 (정상적인 페이지 방문) 페이지 뷰 수를 넘게 할 수 없음 - 뒤로가기를 통해 click 을 하여 이벤트가 무분별하게 기록될 시 ctr 100% 를 넘어갈 수 있기에, 별도의 처리가 필요하나 함부로 생략/가공 시 black box 이슈가 생김으로 로그를 최대한 있는 그대로 남기면서 ctr 100% 넘지 않는 처리가 필요함
 - 따라서 뒤로 가기 클릭을 통해 click 한 경우 우선은 해당 뒤로가기 방문 로그에 is_click 을 true 로 하여 기록은 남김
 - 뒤로가기를 통해 버튼을 클릭했다면, 이는 원래 이전에 정상 방문한 페이지 중 하나에서 클릭을 했다는 의미
 - 따라서 정상 방문한 페이지 (
is_routing_back이 true 가 아닌) 중 CTA 를 클릭하지 않은 (is_click이 true 가 아닌) 로그를 찾아서 그 페이지에서 클릭을 했을 것이다로 처리. - 대신 기록을 정확하게 남기기 위해 
click_by_back에 뒤로가기를 통해 방문한 로그의 id를 기록하여 향후 유기적으로 판단 가능할 수 있도록 처리 - User journey 를 보여줘야할 경우 페이지 방문과 뒤로가기/앞으로가기를 통해 방문한 케이스를 다 보여줄 수 있음
 - 뒤로가기/앞으로가기 하여 버튼을 클릭했다는 세부 정보도 알려줄 수 있음
 - CTR 계산 시 추가로 복잡한 처리나 가공 없이 기존 방식 그대로 is_routing_back 이 true 가 아닌 페이지뷰와 is_click 값을 찾아서 계산해줄 수 있음
 
DECLARE
    current_log RECORD;
    prev_log    RECORD;
BEGIN
    -- 1. Fetch the latest log row (by highest id) for the given session_id and post_id,
    --    but only select the columns we need.
    SELECT id, is_routing_back, is_click
      INTO current_log
      FROM logs
     WHERE session_id = p_session_id
       AND post_id    = p_post_id
     ORDER BY id DESC
     LIMIT 1;
    IF NOT FOUND THEN
        RAISE EXCEPTION 'No log record found for session_id: %, post_id: %',
                        p_session_id, p_post_id;
    END IF;
    -- 2. If the current_log is already clicked, return early.
    IF current_log.is_click THEN
        RETURN;
    END IF;
    -- 3. If is_routing_back is false, mark the current log as clicked and return.
    IF current_log.is_routing_back = false THEN
        UPDATE logs
           SET is_click = true
         WHERE id = current_log.id;
        RETURN; 
    END IF;
    -- 4. If is_routing_back is true:
    --    (a) Mark the current log as clicked.
    UPDATE logs
       SET is_click = true
     WHERE id = current_log.id;
    --    (b) Find the most recent previous log (by id < current_log.id) where:
    --       - is_routing_back = false
    --       - is_click = false or is_click IS NULL
    --       (and has the same session_id, post_id).
    --    Only select the 'id' of that log, as that’s all we need for the update.
    SELECT id
      INTO prev_log
      FROM logs
     WHERE session_id       = p_session_id
       AND post_id          = p_post_id
       AND is_routing_back  = false
       AND (is_click = false OR is_click IS NULL)
       AND id < current_log.id
     ORDER BY id DESC
     LIMIT 1;
    -- 5. If no matching previous log is found, just return.
    IF NOT FOUND THEN
        RETURN;
    END IF;
    -- 6. Otherwise, mark that previous log as clicked and set click_by_back to the current log's id.
    UPDATE logs
       SET is_click      = true,
           click_by_back = current_log.id
     WHERE id = prev_log.id;
END;
해당 log 의 
is_click true 로 수정, 동시에 click_by_back 의 id 값에 4546157 값을 넣어 출처를 기록⇒ 뒤로 가기 로그에서의 클릭을 기록함과 동시에, 일반적인 방문을 통한 로그의 클릭을 수정해주며 출처를 기록
위의 방법을 사용한 이유
위 방법의 장점
Share article