✨ 기억보다 기록을/트러블슈팅

🔥자유게시판 성능 개선 SSul (Supabase + React Quill 최적화)

서카츄 2025. 5. 2. 07:27

프로젝트를 배포하고나서 프로덕션 환경에서 확인해보니

자유게시판이 다른 페이지에 비해 상대적으로 느린 로딩 속도를 보여주고 있었다.🥲

흐린눈 하고 있었는데 페이지 들낙날락 이동해보니 너무 킹받아서 결국 다시 뜯어 고치기로 함(....)

 

자유게시판 성능을 개선해보기 위해서 일단 어떤 것이 문제였는지 파악해보기로 했다.

 

 

문제의 원인?

프로덕션 환경에서 네트워크 탭을 확인해보니, 자유게시판 목록 로딩 시 Supabase 요청이 `2.6MB`나 되는 데이터를 반환하고 있었고

전체 페이지 로딩 시간의 절반 이상을 차지하고 있었다.

이거 때문에 로딩 시간이 길어지고 있었고 로딩시간이 다른 페이지 대비 `718ms`로 상대적으로 느린 속도로 로딩되고 있었던 것!

+ 특히 이미지가 많았던 게시글 리스트를 클릭하면 로딩속도가 현저히 느렸다... 성격급한 나도 답답해서 끄게 됐는데

실 사용자가 자유게시판을 이용한다면 성질나서 그냥 이용 안할 것 같았다 😂

 

 

내가 생각한 문제파악

1. Supabase 패칭 시 "*"로 게시글 전체 + user, comments, likes까지 join + count

    → 데이터가 매우 무거움...

    →  페이지네이션으로 제한이 걸려 있지만 select 범위가 과함

    → 그래서 느리지 않을까? 생각됨

 

2. Supabase에서 댓글 수, 좋아요 수를 각각 count로 집계하면서 성능 부담 발생

    → 클라이언트에서 count를 처리하고 있음...계산까지 하고 보내주려니 로딩속도가 오래걸림...

 

3. 게시글 content 내부에 이미지가 많아지면서 문자열이 무거워짐

    → react quill 라이브러리에서 base64로 이미지를 저장해버림....긴 문자열 처리로 매우 느려짐

 

 

이유를 찾아보고, 어떻게 해결해야할지 고민해보았다.

 

 

 

1. Supabase 패칭 시 "*"로 게시글 전체 + user, comments, likes까지 join + count

   let query = supabase.from("board").select(
      `
        *,
        board_comments!board_comments_board_id_fkey(count),
        board_likes(count),
        user!inner(id, name, avatar_url)
      `,
      { count: "exact" },
    );

 

Supabase의 코드는 이렇게 되어있다. 여러 테이블 끼리 join으로 엮여 있어서 느리지 않을까? 싶었다.

* 에서 필요한 컬럼만 가져와보도록 수정해보려고 했는데

 

 

자유게시판 테이블 안에 있는 컬럼들을 확인해보니 어라...? 전부 쓰고있는데..? 해서

코드는 다시 냅두게 되었다.

 

 

 

❌ 1번은 아니었다??? 🤔

2. Supabase에서 댓글 수, 좋아요 수를 각각 count로 집계하면서 성능 부담 발생

 

아무리 생각해봐도 이 부분이 너무 거슬렸다. 좋아요와 댓글 count를 수파베이스 로직에서 계산해서 뿌려주는 방식이었는데

이걸 클라이언트측에서 카운트 수를 일일이 계산하다 보니까 이것 때문에 성능 부담이 되지 않을까?? 라고 단순하게 일단 생각해 봄...

굿즈샵 페이지를 제작하면서 할인 평균값이나 평점 평균 같은 것을 직접 제작해보면서 많이 힘들었기 때문에 ㅠ

기본적으로 백엔드와 같이 협업했으면 이미 카운팅 수를 계산해서 보내주고, 프론트측에서는 받아서 해결할 수 있었을텐데 싶었다.

이럴때 백엔드 공부를 했어야 했나 너무 답답했다. 어떻게 처리해야할지 막막했다 ㅠㅠㅠㅠㅠ

 

그래서 이럴때는 GPT의 힘을 빌려서, 어떻게 해야하는지 토론을 해보았다 😂

 

 

지피티도 느린 원인을 알고 있었다...

확장성을 생각해보고, 또 사용자들이 늘어나서 좋아요 수, 코멘트 수가 기하급수적으로 늘어난다면

진짜 이건 테이블 마다 각각 count를 계산해서 돌려야하니 당연히 느릴 수 밖에 없었던 것...

 

 

 

✅ 토론 후 지피티가 추천해준 방식

 

 

가상의 뷰를 만들기?????

View 테이블을 만들어서 미리 카운팅하고, 클라이언트 측에 보내는 방법 어때? 라고 해결방안을 제시해줄때 

어??? 이거 대학교 데이터베이스 시간에 배웠던 (...) 무려 10년전에 기말고사로 나왔었던 생각이 사르륵 떠올랐다.

View 키워드를 보자마자 와 맞다 가상테이블!!!! 기억은 안나는데 View = 가상테이블이었던 키워드만 생각남 ㅋㅋㅋ 

 

옛날에 배워서 기억이 가물가물했지만🥲

View테이블을 만들 쿼리문을 부탁하고 가상의 테이블을 만들었다.

 

 

가상테이블을 만들고, 그 안에 필요한 컬럼들 + comment_count와 like_count 컬럼을 만들어서

가상테이블 안에서 바로 카운팅 하도록 만들었다.

 

 

 

가상테이블로 가져왔다. 코드가 매우 단순해졌다.

   let query = supabase.from("board").select(
      `
        *,
        board_comments!board_comments_board_id_fkey(count),
        board_likes(count),
        user!inner(id, name, avatar_url)
      `,
      { count: "exact" },
    );
    let query = supabase
      .from("board_with_meta")
      .select("*", { count: "exact" });

 

기존엔 여러 테이블을 join해야 했지만, view 테이블 하나만 불러오면 끝! 단순하게 바뀌었다.

 

 

 

 

//기존 코드
<strong className="font-bold">{item.board_comments[0]?.count || 0}</strong>

//바뀐 코드
<strong className="font-bold">{item.comment_count}</strong>

 

item을 뿌려주는 컴포넌트에서 카운트 수 부분도 단순해졌다😊

 

여기까지만 해도 성능 해결이 된 줄 알았지...? 어림도 없지 ㅎㅎ

 

 

 

 

 

3. 게시글 content 내부에 이미지가 많아지면서 문자열이 무거워짐

제일 문제가 된 부분은 역시 이미지였다.😂

이미지 처리에 대해서 최적화를 잘 진행했어야 했는데, 그걸 미처 파악을 못했다 (ㅠㅠ) 

아무리 Supabase를 경량화 하더라도 소용이 없었다. 느린건 똑같이 체감이 되었던 것이었다....

내부에 이미지가 많아지면서 문자열이 매우 무거워지고, 용량이 커지면서 결국 이미지 문제가 가장 컸다.

 

 

 

내가 생각한 3번의 문제점

1. 글 작성하는 부분을 react quill 라이브러리를 사용하고 있었고, 기본적으로 이미지를 base64로 저장하고 있었다.

너무 긴 문자열로 인해 렌더링 할때 이미지가 많아질수록 변환하는 과정 때문에 로딩이 꽤 무거웠다.

물론 수파베이스에서 직접 들어가서 확인해도, 문자열이 길다고 클릭조차 안되고 있었으니(...) 이걸 먼저 고쳐야 하는게 급선무였다.

 

2. 자유게시판의 메인페이지 썸네일 이미지 문제 

자유게시판의 리스트는 자유게시판 페이지 뿐만 아니라 메인페이지에 있는 자유게시판까지 두개가 있다.

자유게시판에 글 작성을 하고 나서 사용자가 이미지를 여러개 올린다고 가정한다면,

첫번째 사진을 추출해서 메인페이지에 있는 자유게시판 썸네일 이미지로 사용하고 있었다.

그럼 이것도 첫번째 이미지만 추출해서, 가상의 테이블 View에 넣어버리면 해결되지 않을까? 싶었다.

 

하지만 2번은 곰곰히 생각해보면, 결국 이미지 최적화가 안되어있는데 가상의 View 테이블에 이미지를 또 때려박아버리면

똑같이 느려서 의미가 없어질거같은데??? 라는 생각이 들었다.

 

 

1번과 2번을 고민하면서 내가 생각한 해결방안

1. base64로 저장되는 것을 Supabase storage로 넣어서 저장하고, 그 링크들을 저장해두기!

2. 자유게시판 column에 새로운 thumbnail 이라는 이름을 추가해서 사용자가 이미지를 올렸을 때, 첫번째 사진을 추출해서 thumbnail 컬럼에 저장하기! 

 

→ 즉, Supabase에서 제공하는 storage에 이미지를 저장한 뒤, 그 이미지의 링크를 가져오고, 자유게시판에 글을 작성할 때 여러개의 사진 중 첫번째 사진을 thumbnail 이라는 컬럼에 저장해서 넣어두기!

 

 

갑자기 스토리지..?

이미 굿즈샵 페이지의 이미지들과 유저아바타 이미지의 저장관리는 수파베이스에서 제공하는 스토리지를 잘 사용하고 있다.

통일감 있게 자유게시판에 있는 게시글 이미지들도 수파베이스에서 제공해주는 스토리지를 활용하기로 했다 ㅎㅎ

수파베이스에서 제공하는 스토리지를 쓰는 이유는...? 다른 외부 라이브러리를 활용해서 저장하려니

자유게시판 혼자 이미지를 다른곳에 관리한다? 일단 관리 측면에서 어렵다고 생각하였다(...)

사실 돈이 가장 문제이긴 했다...;; 요새 돈 내는 곳이 많아서 수파베이스의 스토리지는 무료로 제공해준다는 것이 장점이었다...

무료로 제공해주는 것을 잘 활용하기😂

+ 이미지 리소스를 통합적으로 관리하기 위하기는 덤....!

 

 

 

 

React Quill 라이브러리 수정하기

import { supabase } from "@/lib/supabase/client";
import { toast } from "@/hooks/use-toast";
import type Quill from "quill";

export const getQuillModules = () => {
  return {
    toolbar: {
      container: [
        [{ header: [1, 2, 3, false] }],
        ["bold", "italic", "underline"],
        ["image"],
      ],
      handlers: {
        image: async function (this: { quill: Quill }) {
          const input = document.createElement("input");
          input.setAttribute("type", "file");
          input.setAttribute("accept", "image/*");
          input.click();

          input.onchange = async () => {
            const file = input.files?.[0];
            if (!file) return;

            const safeFileName = file.name.replace(/[^a-z0-9.]/gi, "_");
            const filePath = `board/${Date.now()}-${safeFileName}`;

            const { error } = await supabase.storage
              .from("boards")
              .upload(filePath, file);

            if (error) {
              toast({
                title: "이미지 업로드 실패",
                description: error.message,
                variant: "destructive",
              });
              return;
            }

            const imageUrl = supabase.storage
              .from("boards")
              .getPublicUrl(filePath).data.publicUrl;

            const range = this.quill.getSelection();
            if (!range) return;

            this.quill.insertEmbed(range.index, "image", imageUrl);
          };
        },
      },
    },
  };
};

 

기존 React Quill 문서를 보면서 기본적으로 제공했던 module만 적었는데, 

외부 라이브러리 기반이라 동작 방식만 이해하고 사용했었다.

하지만 수파베이스에 있는 스토리지에 저장하는 과정이 필요해서 Supabase Storage를 연동해 즉시 업로드하고

해당 URL을 에디터에 삽입하는 기능을 커스텀 핸들러로 구현했다.

 

사용자가 에디터에서 이미지를 삽입하면, supabase.storage.from(...).upload()로 업로드한 후(수파베이스 스토리지)

업로드가 완료된다면 `getPublicUrl()`로 접근 가능한 이미지 URL을 가져와서

Quill의 `this.quill.insertEmbed()` 의 메서드를 사용하여 에디터에 이미지를 바로 삽입한다.

this는 툴바를 가리키므로, 직접 에디터 인스턴스에 접근하기 위하여 this.quill 문을 사용했다.

 

 

 

나머지 코드는 건드릴 필요 없어서, 이미지를 저장하는 코드만 바꾸고 다시 네트워크 탭에 들어가서 확인해 보았다.

 

 

로드 시간  : 기존 718ms → 209ms (약 70.9% 감소)

fetch 요청 용량 : 기존 2.6MB → 1.3KB (약 99.95% 감소)

fetch 응답 속도 : 기존 432ms → 57ms (약 86.8% 감소)

 

불필요하게 포함되던 이미지 데이터만 분리했을 뿐인데, Supabase Storage로 관리하면서 성능이 획기적으로 향상되었다...;;;

리팩토링 이전에는 대체 어떻게 지냈는지 의문이 들 정도였다 ㅠ

드디어 이제 자유게시판 페이지가 빨라져서 편-안...!!

 

 

 

 

 

Lighthouse 성능

 

라이트하우스도 성능이 어마어마하게 좋아졌다. 성능이 무려 98점으로 나왔다 🤣👍👍👍👍👍

 

 

 

개선 후기....!

이번 자유게시판 개선을 통해서 View를 활용한 테이블 분리, Supabase Storage를 이용해서 이미지 처리 방식 개선

React Quill 성능 최적화 등등...너무 막막해서 해결방안이 없을 줄 알았는데

차근차근 하다보니 결국 해결했고 성능 개선을 무사히 할 수 있었다.

특히 실시간이 중요한 게시판의 성능이 정말 중요했는데, 사용자 경험을 해치지 않고 게시판의 성능을 유지하는 방법을 고민한 것이

큰 수확이었다 ㅠㅠ