NOMO.asia

티스토리를 SPA(단일 페이지 어플리케이션) 처럼 만들기

티스토리는 기본적으로 모든 링크를 클릭할 때 마다 페이지를 매번 이동하는 구조로 되어있다. 그런데 적어도 내 스킨에서 각 링크를 클릭할 때마다 바뀌는 부분은 본문 영역 뿐이다. 그래서 티스토리를 SPA(Single Page Application)처럼 단일 페이지에서 별도의 페이지 이동 없이 내용을 동적 로딩하도록 만드는 방법이 있을까 고민해보았다.

처음에는 티스토리 API 에서 글목록, 글내용을 가져와 Ajax 로 뿌려주는 것을 구상했다. 그런데 티스토리 API는 이용자가 항상 토큰을 발급받아야 하고, 토큰 발급 전 티스토리 로그인이 강제되므로 티스토리에 로그인하지 않을 방문자가 대부분인 이 경우에는 적합하지 않았다.

그래서 Ajax 로 html 문서를 호출해서 필요한 부분만 뽑아낸 뒤 동적으로 삽입해봤는데 생각보다 잘 적용되고 속도도 무척이나 빨라서 바로 적용했다. js 와 css 를 로드하고 렌더링하는 과정이 생략되어서 속도가 무척 빠르기 때문에 로딩 중 여부를 표시하는 loader 도 만들어 줄 필요가 없었다.

그리고 본문을 불러오기 전에 내맘대로 컨트롤 할 수 있으므로, 이미지에 대해 lazyload 를 적용할 수 있었다. 현재 첫 접속 시에는 lazyload 인 척만 하고있고, 페이지를 한 번 이동하는 순간부터 제대로 lazyload 하도록 되어있다. 의도한 것은 아니지만 첫페이지에서는 서버 렌더링을 하고 이후에는 동적으로 내용을 받아오는 바람직한 구조가 되었다.

현재 완성 후 대충 테스트를 해봤는데 큰 문제는 없는거같다. 일단 문제가 발생하더라도 에러처리를 해놔서, 문제가 발생하면 그냥 페이지를 이동해버리기 때문에 페이지가 멈춰버리거나 하는 문제는 없다.

기본 동작 흐름

1. 이벤트 바인딩

동적 로딩이 동작해야 하는 경우는 크게 두 경우이다.

 ① 블로그 내부 링크에 해당하는 a 태그를 클릭하는 경우 (onclick event)

  • 클릭한 링크가 새 창으로 열도록 되어있는 경우 동적 로딩하지 않도록 한다. (target = "_blank")
  • 기본 이벤트를 막아서 페이지를 이동하지 않도록 한다. (e.preventDefault())
  • 내부 링크인지 외부 링크인지 판단하여, 내부 링크인 경우에만 동작하도록 한다.
  • 내부 링크인지 여부는 주소가 ?(페이징 시), / 으로 시작하는 경우와, 내 도메인 주소인 https://nomo.asia 로 시작하는 경우이다.
    정규표현식을 이용하여 체크한다.
  • 주소는 a 태그의 href 속성으로부터 가져온다.
  • 최종적으로 동적 로딩을 해야하는 경우 window.history.pushState 를 이용하여 주소를 원해 이동해야 했을 주소로 새롭게 갱신한다.
    (탐색할 때 주소의 형태를 해시뱅 (#!) 형태로 바꾸면 구현은 쪼~금 더 편하긴 하지만 기존 티스토리 주소 룰과 다르므로 새로고침 하거나 외부에서 해시뱅 형태의 링크로 접속하는 경우 리다이렉트 해줘야하므로 불편하다.)
  • 최종적으로 동적 로딩을 하지 않아야 하는 경우 window.location.href = 주소 를 이용하여 페이지를 이동한다.

참고로 모든 a 태그에 이벤트를 걸 수도 있으나 이 경우 부작용이 발생할 수 있어서 원하는 엘리먼트의 링크를 클릭했을 때만 동작하도록 이벤트를 바인드 하였다. 예를 들어, 스킨에 유저가 정의하지 않았는데 티스토리에서 자동적으로 만들어내는 링크들이 있고, 이 부분을 클릭했을 때 오동작을 할 수도 있다.

 ② 뒤로, 앞으로 키를 이용하여 블로그를 탐색하는 경우 (onpopstate event)

  • popstate 이벤트는 뒤로, 앞으로 등을 이용해 사용자가 새로운 상태로 이동할 때 작동한다.
  • 어차피 내부에서 탐색하는 경우에만 동작할 것이므로 내외부 링크에 대한 판단은 하지 않는다.
  • popstate 이벤트가 발동하면, window.location.href 의 값은 이미 이동한 페이지의 값을 갖는다.
    따라서 주소는 window.location.href 로부터 가져온다.

2. Ajax 호출 (동적 로딩)

각 이벤트에서 동적 로딩이 이루어져야 된다고 판단되면 아래와 같은 동작을 실행한다.
  • 이벤트에서 넘긴 주소값을 이용하여 Ajax 호출하여 html 문서를 텍스트 형태로 가져온다.
  • 가져온 html 문서를 DOMParser 를 이용해 쉽게 파싱할 수 있는 구조로 변환한다.
    그냥 jQuery 만도 이용해봤는데 DOMParser 를 한 번 거치는 것이 head 등을 탐색하는데 더욱 안정적이었다.
  • 동적 로딩해야할 본문에 해당하는 element 를 찾아 복사한다.
  • 현재 페이지의 본문에 해당하는 element 를 복사한 element 로 대체한다.
  • 스크롤을 맨 위로 올린다.

추가적인 처리

구조는 생각보다 단순했고 구현도 쉽게 됐는데, 의도하지 않은 방식으로 동작하는 경우가 있어서 추가적인 처리를 할 부분이 많았다.

SyntaxHighlighter 문제

새로 불러올 글에 SyntaxHighlighter 가 적용될 내용이 있는 경우, Ajax로 호출해오면 SyntaxHighlighter 가 적용되지 않은 상태로 삽입됐다.
동적 로드 후, SyntaxHighlighter.highlight(); 를 호출하여 해결했다.
그 외에 $(document).ready 에 선언해준 것중 글이 새롭게 불러와질 때마다 재호출해야 하는 것이 있다면 동적 로드 후 함수를 재호출 해야한다. 내 경우에는 목차를 페이지 로드 후 JavaScript 를 이용해 동적으로 생성해주기 때문에, 동적 로드 후 새로 불러와진 문서에 대하여 목차를 다시 생성해주어야 했다.

목차 탐색 문제

티스토리에서는 목차 기능을 제공하지 않지만, 본 블로그에서는 자동으로 목차를 작성해주는 스크립트를 작성 및 적용하여 PC 화면에서만 적용 중이다. 목차 링크를 클릭하면 주소 맨 끝에 #이 붙어서 동일한 페이지 내에서 스크롤을 이동하게 되며, 이 때 click 이벤트와 popstate 이벤트가 모두 발동한다. 이 경우에는 Ajax 호출을 하면 안 되고, 일반 링크처럼 동작하며 탐색해야한다.

처음에는 단순히 이동할 주소에 #이 포함되어 있다면 ajax 호출을 하지 않도록 했다. 그랬더니 링크 클릭 시에는 문제가 없었지만, 앞뒤로 이동할 때 문제가 발생했다. 예를 들어 목차를 한 번 클릭한 후(주소에 #이 붙는다), 아예 다른 페이지로 이동 후(주소에서 #이 사라진다), 다시 뒤로 이동했을 때는(이전 페이지 주소에 해당하므로 주소에 #이 다시 생긴다) 주소에 #이 포함되어 있더라도 이전 문서의 내용을 다시 불러오기 위하여 Ajax로 내용을 로드해야 한다. 그런데 단순히 이동할 주소에 #이 포함되어 있다면 Ajax 호출을 하지 않도록 한 경우에는 이 때 Ajax 호출을 하지 않아 동적 로딩되지 않고 페이지를 이동해버리거나 멈춰버리는 문제가 있었다.

그래서 Ajax 호출단에서 목차인지 여부를 체크하지 않고, 각 이벤트 시작 단에서 체크하도록 했다.

  • 클릭 이벤트의 경우에는 어차피 목차 링크의 href 값은 #으로 시작하므로, 내부 링크는 / 이나 https://nomo.asia 와 같이 시작해야 하는데 그렇지 않으므로 내부 링크가 아닌 것으로 판단하여 동적 로딩 하지 않는다.
  • popstate 이벤트의 경우에는 페이지 이동 이전 url 을 저장한 뒤, 이전 url 의 # 앞부분이 현재 url 의 # 앞부분과 다른 경우에만 동적 로딩 하도록 한다.
    예를 들어, http://nomo.asia/400#s-5 에서 http://nomo.asia/300 으로 이동했다가 뒤로 이동 시, # 앞의 주소가 다르므로 동적 로딩한다.
    하지만 http://nomo.asia/400#s-5 에서 http://nomo.asia/400#toc-400 으로 이동했다가 뒤로 이동 시, # 앞의 주소가 같으므로 동적 로딩을 하지 않는다.

이로 인해 목차를 클릭하여 이동할 때는 Ajax 호출 없이 일반 링크처럼 동작하도록 하여 해결했다.

타이틀 적용 문제

본문만 가져오면 타이틀은 이전 글의 타이틀 그대로이다.
타이틀도 가져와서 바꾸도록 하여 해결했다.

구글 애드센스 문제

동적으로 본문을 삽입 시, 본문에 삽입한 구글 애드센스가 뜨지 않는 문제가 있었다.

반응형 광고만 그런지는 모르겠지만 애드센스에서는 광고가 삽입될 영역을 체크하는데, 본문에 삽입되기 전 위와 같이 광고가 삽입될 영역이 존재하지 않는다고 판단하여 광고를 삽입하지 않도록 처리된 듯싶었다.

이것은 본문의 애드센스를 삽입할 때 사용하는 코드를 다음과 같이 수정하여 해결했다.

//(adsbygoogle = window.adsbygoogle || []).push({}); // 기존 코드
$(document).ready(function(){
	(adsbygoogle = window.adsbygoogle || []).push({}); 
});

호환성 문제

작성한 코드에서 DOMParser, window.history.pushState 등의 함수를 사용하는데 브라우저 호환성 문제가 있을 수 있었다.
따라서 저 두가지의 type 이 function 이 아닌 경우 애초에 이벤트를 바인드하지 않도록 했다.
polyfill 등을 적용해볼 수는 있지만, 어차피 이 블로그는 모던 브라우저가 아닌 브라우저들에 대한 호환성을 전부 무시하고 있다.

검색엔진의 봇 문제

useragent 를 체크하여 널리 알려진 검색봇인 경우 이벤트를 바인드하지 않도록 했다. 혹시라도 검색엔진이 탐색을 잘못해서 내용을 잘못 긁어가는 것을 막기위해서이다.

블로그 내부 검색 동작

블로그 내부 검색창을 이용해 검색하는 경우에도 tistory_to_spa 함수를 호출하여 동적 로딩이 가능하도록 html 에서 기존 검색 input 부분의 코드를 수정했다.

그 외

ajax 호출로 가져온 html 문서가 존재하지 않거나, 파싱 중 복사해 올 element 가 존재하지 않거나, ajax 호출이 실패하는 경우 페이지를 이동하게 하여 아예 무반응 하는 것을 막는다.

최종 적용 코드

최종적으로 본 티스토리 블로그에 적용한 코드는 아래와 같다.

본인 블로그 스킨에 맞도록 수정이 필요하다.

const DEBUG = false;
const elem_whitelist = [".subject a", ".article a", ".list_content a", "#sidebar a", ".searchList a", "#footer a"];
const domain_whitelist = /^(\?|\/|http:\/\/nomo.asia|https:\/\/nomo.asia)/;
const crawlerAgentRegex = /bot|google|aolbuild|baidu|bing|msn|duckduckgo|teoma|slurp|yandex|daum|kakao|naver|yeti/i;
const temp_title = "SU의 블로그";
const fadeInOut_elem = "#content"; // 컨텐츠 로드 전 숨길 엘리먼트
const content_elem = "#borderDiv"; // 컨텐츠를 복사해올 엘리먼트

const content_load_after = function() { // 컨텐츠를 복사해온 후 할 행동
    doc_create($("div.entry"));
    SyntaxHighlighter.highlight();
};

var prev_url = ""; // 이전 url을 저장할 변수를 global 로 선언

function tistory_to_spa_init() {
    if (DEBUG) console.log("tistory_to_spa_init");

    // 검색봇의 경우 패스함
    if (typeof DOMParser !== "function" || typeof window.history.pushState !== "function" || crawlerAgentRegex.test(navigator.userAgent)) {
        return false;
    }

    // 첫 접속 시 url 저장
    prev_url = window.location.href;
    if (DEBUG) console.log("prev_url_init", prev_url);

    // Case1 : 링크 클릭 시 동작
    $(document).on("click", elem_whitelist.join(","), function(e) {
        if ($(this).attr("target") === "_blank") {
            return true;
        }
        if (DEBUG) console.log("click 이벤트 동작");
        e.preventDefault(); // 링크 클릭에 의한 기본 이동 막기
        var href = $(this).attr("href");

        // 내부링크 체크
        if (href !== null && href !== undefined && href !== "#" && domain_whitelist.test(href)) {
            if (DEBUG) console.log("tistory_to_spa - by click", href, e);
            window.history.pushState("", temp_title, href); // 현재 주소창의 url 변경
            tistory_to_spa(href);
        } else {
            if (DEBUG) console.log("클릭 이벤트 예외, href", href, "target", $(this).attr("target"), "domain_whitelist.test(href)", domain_whitelist.test(href));
            window.location.href = href;
        }
    });

    // Case2 : 뒤로가기, 앞으로가기 동작의 경우
    $(window).on("popstate", function(e) {
        if (DEBUG) console.log("popstate 이벤트 동작");
        var href = window.location.href;

        // 이전 url 체크
        if (prev_url !== window.location.href && prev_url.split("#").shift() !== window.location.href.split("#").shift()) {
            if (DEBUG) console.log("tistory_to_spa - by popstate", prev_url, href, e);
            tistory_to_spa(href);
        } else {
            if (DEBUG) console.log("popstate 예외", prev_url, href);
        }
    });
}

function tistory_to_spa(href) {
    prev_url = window.location.href; // 이전 url 갱신
    if (DEBUG) console.log("이전 url 갱신", prev_url);

    $.ajax({
        url: href,
        dataType: "html",
        success: function(data) {
            var parser = new DOMParser();
            var $temp = $(parser.parseFromString(data, "text/html"));

            if ($temp === undefined || $temp.length === 0) { // 에러처리
                window.location = href;
                return false;
            }

            var $newContents = $temp.find(content_elem);

            if ($newContents.length === 0) { // 에러처리
                window.location = href;
                return false;
            }

            // 내용 복사 절차
            $newContents.find(fadeInOut_elem).hide();
            $(fadeInOut_elem).fadeOut();
            $(content_elem).html($temp.find(content_elem).first().html());
            content_load_after();
            $(fadeInOut_elem).fadeIn();

            // head 업데이트
            $("head meta, head title").remove();
            $temp.find("head meta, head title").appendTo("head");

            // 목차 이동 또는 최상단으로 스크롤
            if (href.indexOf("#") !== -1) {
                var doc_id = "#" + href.split("#").pop();
                if (doc_id.length !== 0) {
                    $("html, body").animate({
                        scrollTop: $(doc_id).offset().top
                    }, "fast");
                } else {
                    scrollToTop();
                }
            } else {
                scrollToTop();
            }
        },
        error: function(err) {
            if (DEBUG) console.log("error", err);
            window.location.href = href;
        }
    });
}

// 부드럽게 스크롤 Top 까지 올리기
const scrollToTop = () => {
    const c = document.documentElement.scrollTop || document.body.scrollTop;
    if (c > 0) {
        window.requestAnimationFrame(scrollToTop);
        window.scrollTo(0, c - c / 8);
    }
};

tistory_to_spa_init(); // 함수 실행

[2024년 2월 21일 업데이트] Guestbook 에 적용하는 방법

Guestbook 에 SPA 적용이 안 되어서 어떻게 적용하는지를 질문하는 댓글이 있어 작성한다.

Tistory의 스킨 에서 [ ##_guest_onclick_submit_## ] 치환자를 사용하면 다음과 같이 변환이 된다.

즉 Guestbook 에 글을 남기기 위해 submit 버튼을 누르면 addComment 함수가 호출된다. 이 함수는 아래 경로 js 파일의 47번째 라인에 정의되어 있다.
https://tistory1.daumcdn.net/tistory_admin/userblog/tistory-f8f440db2242aeb799e0d4f3578a4f18e45dcf8e/static/script/common.js

참고로 해당 함수는 global 로 선언이 되어있어서, Chrome 브라우저 기준으로 Console 창에 addComment 라고 친 다음 나온 addComment 함수를 클릭하면 바로 해당 함수의 소스를 볼 수 있는 화면으로 갈 수 있다.

addComment 를 클릭하면 아래와 같이 해당 함수의 소스를 볼 수 있다.

Guestbook 에 글을 남기기 위해 Submit 버튼을 누르면, 잠시 후 "새로고침" 되면서 남긴 글이 표시되는 것을 볼 수 있다. 이 새로고침하는 코드는 아래와 같다.

entryId 가 0, 즉 guestbook 에서 남긴 글이면 window.location 을 addUrlPrefix("/guestbook") 으로 설정한다. 그러니까 현재 blog 주소에다가 /guestbook 을 붙인다는 뜻이다. 즉 새로고침이다.

즉 이것을 SPA처럼 동작하게 막으려면 이 새로고침을 막으면 된다. 방법은 간단하다. Guestbook 에서는 티스토리에서 기본 제공하는 addComment 함수를 사용하지 않으면 된다.

1) addComment 함수의 내용을 복사한뒤 이름을 addCommentCustom 으로 변경하고, window.location 을 설정하는 대신 tistory_to_spa 가 호출되도록 수정한다.

function addCommentCustom(submitButton, entryId){
    (function ($) {
        var MAX_COMMENT_SIZE = 1000;
        var oForm = findFormObject(submitButton);
        var commentInput = oForm.querySelector('[name="comment"]');

        if (!oForm) {
            return false;
        }

        var data = {
            key: 'tistory'
        };

        var $captchaInput = $("#inputCaptcha");
        if ($captchaInput.length > 0) {
            if (!$captchaInput.val()) {
                alert('그림문자를 입력해 주세요.');
                return false;
            }

            data.captcha = $captchaInput.val();
        }

        if (oForm["name"]) {
            data.name = oForm["name"].value;
        }

        if (oForm["password"]) {
            var passwd = oForm["password"].value.trim();
            if (passwd.length == 0) {
                alert('비밀번호를 입력해 주세요.');
                return false;
            }

            var shaObj = new jsSHA("SHA-256", "TEXT");
            shaObj.update(md5(encodeURIComponent(passwd)));
            data.password = shaObj.getHash("HEX");
        }

        if (oForm["homepage"]) {
            data.homepage = oForm["homepage"].value;
        }

        if (oForm["secret"] && oForm["secret"].checked) {
            data.secret = 1;
        }

        if (oForm["comment"]) {
            data.comment = oForm["comment"].value;
        }

        if (typeof data.comment === 'string' && data.comment.length > MAX_COMMENT_SIZE) {
            alert('댓글은 ' + MAX_COMMENT_SIZE + '자까지 입력할 수 있습니다.');
            commentInput && commentInput.focus();
            return;
        }

        if (data.secret === 1 && T.config.ROLE === 'guest') {
            if (confirm('비로그인 댓글은 공개 작성만 가능합니다. 로그인 하시겠습니까?')) {
                window.location.href = T.config.LOGIN_URL;
            }
            commentInput && commentInput.focus();
            return;
        }

        if (submitButton && submitButton.setAttribute) {
            submitButton.setAttribute('disabled', true);
        }

        $.ajax({
            url: oForm.action + '?__T__=' + (new Date()).getTime(),
            method: 'post',
            data: data,
        }).done(function (r) {
            if (entryId == 0) {
                tistory_to_spa(addUriPrefix("/guestbook"));
                return;
            }

            var data = r.data;
            var $comments = $("#entry" + entryId + "Comment"),
                $recentComments = $("#recentComments"),
                $commentCountOnRecentEntries = $("#commentCountOnRecentEntries" + entryId);

            $comments.html(data.commentBlock);
            $recentComments.html(data.recentCommentBlock);
            for (var i = 0; $("#commentCount" + entryId + "_" + i).length; i++) {
                $("#commentCount" + entryId + "_" + i).html(data.commentCount);
            }
            $commentCountOnRecentEntries.html("(" + data.commentCount + ")");

            if (typeof window.needCommentCaptcha !== "undefined") {
                captchaPlugin.init('complete');
            }
        }).fail(function (r) {
            alert(r.responseJSON.message);
        }).always(function () {
            if (submitButton && submitButton.setAttribute) {
                submitButton.setAttribute('disabled', false);
            }
        });

    })(tjQuery);
}

2) 치환자에서 스킨 치환자를 사용하는 대신, addCommentCustom 함수를 호출하도록 하드코딩 한다.

<input type="submit" value="Submit" onclick="addCommentCustom(this, 0); return false;" />

이렇게 하면 GuestBook 에서도 SPA 를 적용할 수 있다.

참고로 GuestBook 의 submit 버튼은 기본적으로는 링크로서 동작하지 않게 해야한다. 즉 당연한 내용이지만 댓글 작성이 완료된 후에 tistory_to_spa 함수가 호출되도록 해야할 것이다.