지금까지 기록했던 할 일들은 이런 것들이 있었다. 블로그 이미지 최적화, 소개 페이지 삽입, 게시판 제목에 링크달기, 글 썸네일 넣기, 글 목록 페이지네이션, 다크 모드, 글 시간순 정렬, 조회수 달기, TOC 자동 생성, 댓글 기능, 검색 기능, 태그 필터링, SEO 정도.
이 중 태그 필터링 정도를 빼면 모두 완료한 상태이다. 태그 필터링은 검색 기능이 있는 이상 당장 필요하지는 않을 것 같다고 생각해서 미루었었다.
하지만 글이 늘어나면서 내가 기존에 생각했던 CS, 개발, 기타 카테고리만으로는 글을 분류하기가 점점 어려워졌다. 개발 카테고리가 비대했던 건 어느 정도 예상했던 일이지만 이 정도로 커질 줄은 몰랐다.
따라서 좀 더 글을 주제별로 분류한다고 생각되는 태그로 글들을 분류하기로 했다. 기존에도 글에 태그를 달아야 했으므로 모든 글에 이미 태그가 있었고 이를 이용하면 된다.
2. 구상
그런데 이 태그를 통한 글의 분류라는 게 막상 하려고 보니까 생각보다 할 게 많은 작업이었다.
단순히 글 목록 페이지에서 글을 태그별로 볼 수 있도록 하는 거였다면 쉬웠겠지만 그 정도가 아니었기 때문이다. 그것에 맞게 사이트 구조와 몇몇 컴포넌트를 바꾸는 작업이 선행되어야 했다.
현재 전체 태그는 약 15개 정도 존재하고, 예전에 붙여 놓았던 잡스러운 태그들을 제거하고 나면 10개 정도 될 것이다. 늘어날 것을 아주 넉넉히 고려해도 두 자릿수 정도에서 놀지 않을까 생각한다.
하지만 지금도 10개를 넘어가는 글의 태그를 헤더에 모두 표시하면서 가시성을 챙기는 건 불가능에 가까운 일이다.
따라서 현재의 폴더 기반으로 글을 분류하고 보여주고 있는데 이를 태그 기반으로 모두 바꾸는 것을 궁극적인 목적으로 하고 사이트가 어떻게 바뀌어야 할지 구상해 보았다.
이렇게 할 시 내가 태그를 좀 신경써서 써야 하는 것은 여전하지만 원하면 태그를 쉽게 새로 만들기만 하면 된다는 점에서 더 유연해진다고 생각한다.
지금은 메인 페이지에서 폴더별로 글을 분류하고 폴더마다 있는 글들을 시간순으로 정렬해서 보여주고 있다. 이를 현재 글이 들어 있는 폴더를 기반으로 하는 게 아니라 태그를 기반으로 하는 것으로 바꿔야 한다. 전체 구상은 다음과 같이 해보았다.
2.1. 폐기된 구상
처음에는 검색 페이지와 태그 필터링 페이지를 합쳐서 하나로 만들까 생각했다. 검색어와 태그를 기반으로 필터링한 포스트만 보여주는 것이다. gatsby-starter-lavender에서 이런 필터링을 사용한다.
하지만 이렇게 하지 않은 이유는 다음과 같다.
첫번째로 구현 난이도와 성능에서 차이를 보인다는 것이다. 태그와 검색어를 통한 필터링을 한 페이지에서 구현할 시 필터링된 결과의 페이지네이션을 미리 생성해 놓을 수 없다.
따라서 페이지번호와 검색어를 쿼리스트링으로 관리하여 해당 쿼리 값을 기반으로 필터링된 결과를 검색 결과로써 보여줘야 한다. /tag?search=검색어&page=2와 같이 말이다.
하지만 이렇게 하면 매번 검색어를 입력할 때마다 다음 과정을 거쳐야 한다.
전체 글에서 검색어에 맞는 글 필터링 -> 해당 글을 페이지로 분할해서 나온 결과를 화면에 뿌려주기
이는 글 분류와 페이지 번호로 정적으로 생성된 페이지(/[tag]/[page]와 같은 URL을 통해서)를 보여주는 것보다 성능이 떨어질 수밖에 없다.
두번째로 검색과 태그를 통한 필터링 그리고 페이지네이션의 조합이 UX에서 별로 좋지 않다고 보인다는 것이다. 내가 현재 구현하고 있는 검색은 사용자의 검색창 입력이 바뀔 때마다 검색 결과를 업데이트해주는 방식이다. 실제로는 완전히 실시간은 아니고 디바운싱 최적화를 적용하지만.
이렇게 하면 사용자가 검색어를 입력한 후 특별한 행동을 취하지 않아도 알아서 검색 결과를 보여주므로 사용자와의 상호작용이 줄어들어서 좋다.
하지만 이런 실시간 검색을 통한 필터링과 페이지네이션이 그렇게 궁합이 좋지 않다. 예를 들어서 A라는 검색어가 타이핑되었을 때 100개의 포스트가 검색되어 10페이지가 생겼고 사용자가 9페이지로 이동했는데(?search=A&page=9), 이 상태에서 B를 검색어에 추가로 타이핑하니까 검색 결과가 25개밖에 안 된다.
그러면 AB를 검색한 결과의 9페이지에서는 뭘 보여줘야 하는가? 검색 결과가 없다고 보여주기? 아니면 검색어를 초기화 해버리기? 혹은 현재 검색결과의 마지막 페이지로 이동? 무엇이든 할 수는 있겠지만 어떤 것을 해도 현재 URL이 바뀌고 페이지 번호도 바뀌어서 사용자가 혼란스러울 것이다.
기존의 검색 페이지처럼 무한 스크롤을 이용해서 검색 결과를 보여준다면 좋겠지만 사용자가 페이지를 제어하고 있다는 느낌을 주는 페이지네이션의 이점을 포기할 수 없었다.
또한 내가 UX디자인에 뭔가 전문성이 있는 것도 아니기 때문에 실시간 검색, 태그 필터링, 페이지네이션의 장점들을 취한 새로운 방식을 만들어내기는 어렵다고 생각했다. 따라서 위의 방식으로 페이지 라우트를 설계하기로 했다.
2.2. 페이지 구조
현재는 /posts/[category]가 각 글 분류의 목록을 보여주는 페이지이고 /posts/[category]/[slug]가 각 글의 상세 페이지이다. 그리고 /posts/[category]/page/[page]가 각 글 분류에서 2이상의 페이지 번호를 가진 페이지를 보여주는 페이지이다.
먼저 복수 태그가 허용되는 것을 생각하면 /posts/[tag]/[slug]와 같은 URL을 갖도록 하는 것은 좋지 않다. 현재의 URL 작명 방식을 생각해 볼 때 서로 다른 태그를 가진 중복된 폴더명이 있을 가능성은 적으므로 글 상세 페이지의 경우 /posts/[slug] 경로를 가지도록 하겠다.
그리고 페이지네이션을 만들 때 /posts/[category]/page/[page]와 같은 URL을 갖게 한 것은 중간에 page라는 단계가 있으면 좋겠어서가 아니었다.
posts/[category]/[page]와 같이 동적 URL을 구성하게 되면 글의 상세 페이지(posts/[category]/[slug])와 같은 형식의 동적 URL을 갖게 되기 때문이었다. 이렇게 2가지의 동적 라우터가 있는 건 권장되지 않는 패턴이기 때문에 어쩔 수 없이 중간에 page를 한번 넣은 것이다.
하지만 태그 기반으로 글을 분류하는 페이지 구조에서는 이런 동적 라우트의 중복이 일어나지 않으므로 2페이지 이상의 페이지를 가질 경우 /posts/tag/[tag]/[page]와 같은 동적 URL을 갖도록 하겠다. 모든 글을 보여주는 페이지는 /all라우트를 따로 만들어서 /posts/all과 /posts/all/[page]가 될 것이다.
글들의 모든 태그를 뽑아내는 함수를 넣기 위해 src/utils/postTags.ts를 만들고 함수를 작성한다.
이번에는 태그를 기반으로 해당 태그를 가진 글을 뽑아내는 함수를 만들자. 기존의 getCategoryPosts는 다음과 같이 동작하였다. 딱 필요한 페이지만큼의 글을 불러오기 위한 PageInfo정보가 제공되었다.
이를 기반으로 비슷한 getPostsByPageAndTag를 만들자. 이 역시 태그에 해당하는 글들을 페이지로 분류해야 할 것은 마찬가지이므로 요구되는 정보는 똑같다.
4. 페이지 구조 만들기
앞서 만들었던 페이지 폴더 구조가 실제로 작동하도록 해보자. 먼저 모든 글들을 posts/cs 와 같이 분류하는 폴더 내부가 아니라 posts로 옮긴다.
4.1. 태그별 페이지 구성하기
먼저 태그별로 글을 분류해 놓은 페이지를 구성하자. src/pages/posts/tag/[tag]/index.tsx를 편집하자.
모든 태그를 가져오는 getAllPostTags를 이용해서 가능한 모든 태그들의 경로를 제공한다.
그리고 이렇게 만들어진 path를 기반으로 getPostsByPageAndTag를 이용해서 해당 태그를 가진 글들을 가져오는 getStaticProps를 만들자. 기존에 있던 해당 함수에서 태그를 기반으로 하는 것을 감안해 변수명과 링크만 좀 바꾸면 된다.
페이지 컴포넌트는 이렇게 생성된 tag, tagURL을 사용하도록 변경한다.
이제 2페이지 이상의 페이지를 담당하는 파일을 편집하자. src/pages/posts/tag/[tag]/[page]/index.tsx를 본다. 일단 getStaticPaths에서 태그별로 페이지들을 생성하도록 바꿔야 한다. ISR을 이용하는 기존의 코드를 그대로 가져와서 태그를 기반으로 하도록 바꾸기만 했다.
그리고 getStaticProps에서는 이렇게 만들어진 페이지들을 기반으로 해당 페이지에 들어갈 글들을 가져오도록 한다. 기존 코드를 약간만 변경했다. blogCategoryList에서 해당 카테고리의 제목과 URL을 찾는 과정이 없어져서 조금은 간단해졌다.
이를 담당하는 페이지는 동적 라우트 대신 명시적으로 만든 posts/all과 posts/all/[page]이다. 전체 글은 태그를 통한 분류가 아니기 때문에 /tag 가 URL에 붙는 게 적절하지 않다고 생각했다. 동적 라우트 중복을 막기 위해서도 이편이 더 낫다. 단 글을 쓸 때 all이라는 URL 경로를 생성하지 않도록 주의해야 한다.
먼저 src/pages/posts/all/index.tsx를 만들자. 앞서 만들었던 태그 페이지와 거의 비슷하다. 다음과 같이 getStaticProps를 작성하고 컴포넌트를 적당히 만들면 된다.
상세 페이지의 getStaticProps는 이렇게. 페이지가 1일 때는 /posts/tag/all로 리다이렉트하도록 한 것과 없는 페이지가 not found가 되도록 하는 처리가 추가적으로 들어갔다. 나머지 컴포넌트 구조는 같다.
4.3. 글 상세 페이지
기존의 src/pages/posts/[category]/[slug]/index.tsx를 src/pages/posts/[slug]/index.tsx로 만들었다. 이제 이를 기반으로 글 상세 페이지를 만들자.
getStaticPaths에서는 모든 글들의 경로를 만들어야 한다. 이제 모든 글들이 /posts 폴더 안에 바로 존재하므로 그냥 post._raw.flattenedPath를 slug path로 사용하면 된다.
getStaticProps는 이를 이용해서 해당 글의 정보를 가져오도록 한다. 기존의 코드를 약간만 바꾸면 된다.
4.4. 메인 페이지
메인 페이지에서는 최근에 올라온 글을 9개만 보여주도록 하자. 3개짜리 글 3줄. 나쁘지 않다.
5. 다른 수정사항
5.1. 헤더 수정
폴더 기준으로 구분되어 있던 헤더의 구성을 바꾸자. /posts/all페이지와 검색 페이지, /about 만 남기면 될 것 같다. 원래는 이것들도 아이콘으로 하려고 했는데 굳이 그럴 거 없을 거 같다. 참고로 글 목록 페이지에는 모두 태그를 통해서 다른 태그 분류 페이지로 이동할 수 있는 기능을 넣을 예정이기 때문에 /posts/all만 넣어도 상관없다.
현재 헤더는 navList를 props로 받아서 해당 메뉴들을 내비게이션 바로 표시해 주는 방식이므로 이를 담당하고 있는 /blog-category.ts를 수정하면 된다.
5.2. 태그를 통한 필터링
글을 태그 기반으로 분류하면서 글의 분류가 훨씬 더 늘어났다. 줄이려면 좀 줄이겠지만 그렇게 하더라도 CS, 개발, 기타 뿐이었던 3종류보다는 많을 것이다. 이것은 더 이상 헤더에 모든 글 분류를 나열하기 힘들어졌다는 것이다.
물론 메뉴를 더 계층화하여 분류할 수도 있다. 하지만 그렇게까지 빡빡하게 분류해야 한다면 태그 시스템을 쓰는 의미가 별로 없다고 생각한다. 따라서 위의 헤더 수정 파트에서도 글 목록 페이지에 /posts/all 하나만 넣은 이유를 설명하면서 이야기했지만 글 목록 페이지에서 태그를 통해서 다른 글 분류 페이지로 이동할 수 있도록 하겠다.
원래는 메뉴 버튼을 누르면 드롭다운 메뉴가 나오고 그곳에서 태그를 선택하여 해당 태그에 대한 페이지로 가는 방식도 고려했다.
하지만 그렇게 하면 UI를 생각했을 때 사용자가 글 목록을 보기 위해서 상호작용을 한번 더 해야 하고 접근성 관점에서 생각해 봤을 때도 글 분류에 접근하는 방식이 드롭다운이라면 스크린 리더가 제대로 읽지 못할 거라 생각했다.
따라서 글을 보는 페이지에서 태그별 글 분류 페이지로 이동하는 링크가 보이도록 한다. 완성시 다음과 같은 모습이 된다.
하지만 엄청나게 훌륭한 디자인인 것도 아니고(나는 디자이너가 아니니까...) CSS를 하나하나 설명할 필요는 없을 것 같아서 핵심 로직만 코드로 남긴다.
5.2.1. URL 변환 함수
태그는 getAllPostTag로 가져와서 전달할 게 뻔하므로, 태그명을 받아서 URL로 변환하는 함수가 필요하다. 태그 분류별로 페이지가 동적 라우터로 생성되었기 때문에, 각 태그에 대한 동적 라우트 경로로 이동할 수 있는 링크 주소를 생성할 수 있어야 하기 때문이다.
따라서 다음과 같이 src/utils/postTags.ts에 태그명을 받아서 태그 분류 페이지에 대한 URL 링크로 변환하는 함수를 만든다. All의 경우 모든 글을 보여줄 특별한 태그명이며 향해야 할 링크도 다르기 때문에 따로 처리해 준다.
5.2.2. 컴포넌트 제작
그리고 src/components/tagFilter/index.tsx에 태그 필터를 위한 컴포넌트를 만들자. 이 컴포넌트는 무엇을 받아야 할까? 이 컴포넌트에서 자체적으로 결정할 수 없는 요소는 무엇인가?
먼저 전체 태그명을 받아야 한다. 사실 이건 이 컴포넌트에서 자체적으로 만들어 줄 수 있지만, 어차피 이는 상수이므로 따로 src/utils/postTags.ts에 All을 포함한 전체 태그 목록을 넣어 놓고 TagFilter컴포넌트에 props로 전달할 것이다.
현재 선택된 태그는 URL을 통해 명시될 것이므로 컴포넌트에서는 알 수 없다. 따라서 props로 받아야 한다. 그리고 태그를 클릭하면 해당 태그에 대한 페이지로 이동해야 하는데 그 페이지 URL을 만들어 주는 함수도 props로 받아야 한다. 위에서 makeTagURL함수를 이미 제작한 바 있다.
따라서 TagFilter컴포넌트는 다음과 같이 만들어질 수 있다.
그리고 다음과 같이 사용할 수 있다. 이를 글 목록 관련 모든 페이지에 추가해 준다.
6. 플러그인 교정
폴더 구조가 바뀌었기 때문에 prebuild 시점에서 글의 이미지들을 다른 곳으로 복사해 주는 플러그인을 수정해야 한다. src/bin/pre-build.mjs를 다음과 같이 수정한다.
7. 중복 경로 문제 트러블 슈팅
이렇게 하고 빌드를 해보았다. 바로 다음과 같은 에러가 발생했다.
무엇이 문제일까? 일단 의심되는 건 썸네일을 만들고 cloudinary에 업로드하는 로직이다. 따라서 업로드를 하지 않고 로컬에만 썸네일을 저장하도록 변경하였다. 썸네일을 만들고 저장하는 src/plugins/make-thumbnail.mjs의 makeThumbnail 함수를 다음과 같이 변경하였다.
만약 blogConfig.imageStorage가 local이면 cloudinary에 업로드하지 않고 로컬에만 저장하도록 하였다.
그런데 오류 메시지를 잘 보았더니 HTML.html 에 대한 오류인 것을 알 수 있었다. 뭔가 HTML이라는 것에 대한 문제가 있나? 해서 보았더니 HTML이라는 태그가 있었다. 문제는 소문자로 된 html이라는 태그도 있었다는 것이다. 이 둘이 중복 경로를 만드는 게 문제가 되었던 듯 했다.
실제로 html 태그를 모두 HTML로 고치니 빌드가 잘 되는 것을 볼 수 있었다.
각 글들의 태그를 Set을 이용하여 중복을 제거하고 다시 저장할 때는 당연히 HTML과 html문자열은 다르게 취급되므로 두 태그가 모두 고유한 태그로 간주된다. 따라서 src/pages/posts/tag/[tag]/index.tsx에서 태그 분류 페이지를 만들 때도 html과 HTML 태그 문자열에 대한 URL이 모두 만들어지는데 페이지 URL에서는 대소문자 구분이 안되어서 이런 빌드 오류가 발생한 것이다.
8. 태그 줄이기
모호한 태그가 너무 많기 때문에 몇 가지 태그들만 추려서 남기도록 하겠다. 지금 분류가 필요할 것 같은 건 이 정도다. 일단 이들만으로 싹 글들을 정리하였다.