페이지 이동 시간을 단축 시켜보자

2024년 12월 30일

현재 블로그의 문제 분석

현재 위의 성능 지표를 살펴보았을 경우에 블로그의 페이지간 이동 속도가 굉장히 느리다는 것을 알 수 있습니다. 이러한 부분을 개발자 도구의 어떤 지표들을 통해서 확인할 수 있는 지에 대해서 먼저 살펴보겠습니다.

Aggregated time (집계 시간)

위의 집계 시간을 자세하게 살펴보면 어떤 부분에서 얼마만큼의 시간을 소요하고 있는 지에 대한 것을 통계로 알 수 있습니다. 현재 재 블로그에서 페이지 전환에는 대략 3.5 초가 소요되고 있습니다. 이제 위의 그림에서 확인할 수 있는 정보들이 어떠한 정보들이 있는 지에 대해서 더 자세하게 알아 보겠습니다.

Idle 유휴 시간

이 시간이 의미하는 것은 브라우저가 실제 작업을 수행하고 있지 않고 대기하고 있는 시간을 의미합니다. 이 시간이 길다는 이야기는 네트워크 요청은 이루어졌지만 실제로는 어떠한 작업도 수행하고 있지 않다는 의미를 뜻합니다.

긴 유휴 시간이 발생하는 주요 원인들

  1. 비동기 작업의 대기 시간이 길다 가장 일반적인 원인은 비동기 작업을 기다리는 상황입니다. 예를 들어, API 호출이나 데이터베이스 쿼리의 응답을 기다리는 동안 메인 스레드는 유휴 상태로 남아있게 됩니다. 이는 마치 식당에서 주방이 음식을 준비하는 동안 웨이터가 아무 일도 하지 못하고 기다리는 상황과 비슷합니다.
  2. 이벤트 핸들링의 지연 사용자 인터랙션이나 시스템 이벤트에 대한 응답이 지연되는 경우, 브라우저는 다음 작업을 처리하기 전까지 대기 상태에 머무릅니다. 이는 교차로에서 신호등이 바뀌기를 기다리는 자동차들과 유사합니다.
  3. 리소스 로딩의 차단 중요한 리소스 예를 들어 JavaScript 파일이나 이미지가 로드되기를 기다리는 동안, 다른 작업들이 블로킹되어 유휴 시간이 발생할 수 있습니다. 이는 공장에서 필요한 원자재가 도착하기를 기다리느라 생산라인이 멈춰있는 상황과 비슷합니다.
  4. 불필요한 순차적 처리 병렬로 처리할 수 있는 작업들을 순차적으로 처리하는 경우, 각 작업 사이에 불필요한 유휴 시간이 발생할 수 있습니다. 이는 한 번에 여러 접시를 나를 수 있는데 한 접시씩 나르는 것과 같습니다.
앞서 설명한 것과 같이 긴 유휴 시간이 발생하는 것은 성능 저하에 큰 영향을 줍니다. 이러한 이유는 유휴 상태라고 해서 어떠한 작업도 이루어지지 않는 것 같지만 실상은 그렇지 못합니다. 브라우저가 유휴 상태로 있는 동안에도 시스템 리소스는 계속 사용되고 있어서 다른 작업들이 수행될 수 없기 때문에 말 그대로 정지되어 있다고 보는 것이 옳습니다.

Rendering 시간

렌더링 시간은 브라우저가 HTML, CSS, JavaScript를 처리하여 실제 화면에 시각적인 요소들을 그리는데 걸리는 시간을 의미합니다.

렌더링 시간의 지연이 발생하는 주요 원인들

  1. DOM 구조의 복접성
브라우저가 렌더링을 수행할 때는 DOM 트리 깊이와 너비에 크게 영향을 받습니다. DOM 구조가 깊어지거나 넓어질 수록 계산해야 할 관계가 기하급수적으로 늘어나게 됩니다. 예를 들어 보겠습니다.
// 문제가 되는 깊은 중첩 구조
<div id="level1">                 <!-- 1단계 -->
  <div id="level2">               <!-- 2단계 -->
    <div id="level3">             <!-- 3단계 -->
      <div id="level4">           <!-- 4단계 -->
        <div id="level5">         <!-- 5단계 -->
          매우 깊은 중첩
        </div>
      </div>
    </div>
  </div>
</div>
위와 같이 깊은 구조를 브라우저가 계산하는데 많은 시간이 걸리는 이유에 대해서 자세하게 알아 보겠습니다. 브라우저에서 위의 매우 깊은 중첩 의 스타일을 적용하는 과정은 어떻게 될까요?
우선 상속(Inheritance) 을 계산해야 합니다. 브라우저는 각 CSS 속성이 부모 요소로부터 상속이 되는지를 확인해야 합니다. 예를 들어 level1이 font-size, color 와 같은 스타일이 적용되어 있다면 부라우저는 level1 → level2 → level3 → level4 → level5 로 이어지는 모든 경로를 따라가며 이 값이 어떻게 상속되는지 계산해야 합니다.
또한 리플로우(Reflow)의 영향 또한 계산되어야 합니다. 리플로우란 사이트가 생신되고 난 이 후 DOM이 조작되어 웹페이지 내의 DOM요소의 위치와 기하학적인 구조를 다시 계산해야 하는 것을 이야기 합니다. 주로 리플로우 다음에는 시각적인 요소를 다시 그려내는 리페인트가 따라서 옵니다.
DOM 구조 복잡할 경우 이러한 리플로우 계산의 시간 또한 길어집니다. 예를 들어 level5의 크기가 변경되었다고 가정 해보겠습니다. 이 경우 level5 뿐 만이 아니라 level4, level3, level2, level1 모두 재계산이 필요할 수 있기 때문에 깊은 중첩을 갖는 DOM 요소는 성능 저하를 야기할 수 있는 것입니다.
<div class="container">
  <div class="item">보다 단순한 구조</div>
</div>
DOM의 구조가 단순할 경우 단 한 번의 단계로 목표 요소에 도달할 수 있습니다. 요소의 변경이 발생해도 영향을 받는 상위 요소의 수가 적어 재계산의 범위가 제한적이기 때문에 깊은 구조에 비해서 성능 상의 이점을 가질 수 밖에 없고 이러한 이유 때문에 우리는 얕은 DOM 구조를 지향하는 것입니다.

그럼 아래의 경우도 Idle의 비중이 큰 데 비정상일까요?

위의 경우는 정상적인 경우입니다. 성능을 측정할 때는 단순히 비율을 기준으로 평가하지 않습니다. 성능 측정의 시간의 빠른것과 느린 시간을 측정하는 것은 인간의 인지 과정과 연관지어 설명이 됩니다. 이러한 이유는 결국 성능은 사람이 사용하기에 편리한가 불편한가를 평가하기 위한 요소이기 때문입니다.
인간이 일반적으로 인지하는 속도에 대한 인지 과정은 아래와 같습니다.
  • 100ms 이하: 즉각적인 반응으로 인식
  • 100ms - 300ms: 매우 빠른 반응으로 인식
  • 300ms - 1000ms: 자연스러운 전환으로 인식
  • 1000ms 이상: 지연을 느끼기 시작
위의 시간을 기준으로 그림에서 확인할 수 있는 Idle 시간을 평가했을 경우 해당 시간은 사람이 자연스러운 전환으로 인식하기에는 충분한 시간입니다. 하지만 Idle 시간을 평가할 경우에는 절대적인 시간만을 보지 않고 여러 요소들이 고려되어야 합니다.
전체 작업 시간 대비 Idle 시간의 비율은 어느 정도인지, 사용자 인터랙션의 특성은 어떠한 지, 작업의 복잡성은 어떠한 지에 대해서 고려되어야 합니다. 이러한 것을 고려하였을 경우 일반적으로 이상적인 Idle 시간의 기준은 아래와 같습니다.
  • 이상적인 범위: 300ms - 800ms
  • 허용 가능한 범위: 800ms - 1200ms
  • 개선이 필요한 범위: 1200ms 이상

현재 코드의 문제점

비동기 처리를 제대로 처리하지 않고 있었다.

function readMDXFile(filePath: string) {
  let rawContent = fs.readFileSync(filePath, "utf8");
  return parseFrontmatter(rawContent);
}
위의 코드가 기존에 폴더를 읽고 파일을 읽고 있던 방식입니다. fs promise를 이용하여 비동기적으로 파일을 읽지 않고 동기적으로 파일을 읽는 작업을 수행하고 있었습니다. 이러한 부분의 문제점은 다음과 같습니다.
function browseMDXFiles(dir: string, fileList: string[] = []): string[] {
  const files = fs.readdirSync(dir);

  files.forEach((file: string) => {
    const filePath = path.join(dir, file);
    const stat = fs.statSync(filePath);

    if (stat.isDirectory()) {
      fileList = browseMDXFiles(filePath, fileList);
    } else if (path.extname(file) === ".mdx") {
      fileList.push(filePath);
    }
  });
  return fileList;
}
동기적으로 처리할 경우 post 폴더 내부에 있는 모든 파일들을 탐색을 진행해야 합니다. 만약 post 폴더 내에 200개의 포스트 파일이 있다고 할 경우 해당 파일들을 모두 읽을 때 까지 작업은 동기적으로 진행이 되기 때문에 이 작업이 처음 수행되기 이전 까지 다른 작업들은 수행될 수 없다는 문제점을 갖고 있었습니다. 문제는 이 뿐만이 아니였습니다.
export async function generateMetadata({
  params,
}: {
  params: { category: Cateogry; slug: string };
}): Promise<Partial<FrontMatter> | undefined> {
  const { category, slug } = params;
  const frontmatter = await getPostBySlug(slug);
  ...
}

async function Page({ params }: Props) {
  const { category, slug } = params;
  const frontmatter = await getPostBySlug(slug);
  ...
}

async function seedingAlgoPost() {
  const blog = await getBlog();
  ...
}

async function seedingAlgoPost(blog: any) {
  const blog = await getBlog();
  const algoPost = await blog.getAlogPost()
  ...
}
위의 작업들은 블로그의 포스트 내용을 기반으로 메타데이터를 등록하거나 build 시 페이지의 sitemap을 등록하거나 DB에 redirect 에 대한 정보를 저장하여 관리하는 경우 블로그 포스트에 대한 데이터를 읽어오는 작업을 반복하여 수행하고 있었습니다.
이러한 문제들을 해결하기 위해서는 캐싱이 필요하다고 판단하였고 어떠한 캐싱을 사용할 것인지 학습해야 했습니다.

캐싱 방법의 종류에 대해서 파악하기

캐싱을 진행하기 이전에 캐싱을 하는 방법은 어떠한 방법들이 있는 지에 대해서 우선 파악하고 해당 캐싱 방법 중 어떤 것을 적용하는 것이 블로그 포스트에 대한 내용을 관리하는 것에 유리할 지 판단해야 한다고 생각 했습니다.

TTL(Time To Live) 캐시

// tanstack query
const { data } = useSuspenseQuery({
  queryKey: bettingRoomQueryKey(roomId),
  queryFn: () => getBettingRoomInfo(roomId),
  staleTime: 1000 * 60 * 5, // ttl 시간을 설정
});

// swr
const { data, error } = useSWR("/api/data", fetcher, {
  refreshInterval: 3000, // ttl 시간을 설정
});
각 캐시 항목에 유효 기간을 설정하는 방법입니다. 식품에 유통기한을 두고 해당 기간이 지났을 경우 폐기하는 것과 같은 맥락입니다. 예를 들어 이미지에 대한 유효기간이 2시간일 경우 2시간이 지났을 경우 캐싱되어 있는 이미지를 제거하고 새롭게 이미지를 다운로드 받아야만 합니다. 이러한 만료가 된 캐싱 데이터를 삭제하는 방법에도 종류가 있습니다.

적극적 삭제(Active deletion)

이 방식은 주기적으로 만료된 항목들을 검사하여 즉시 제거하는 방법입니다. 이 방식은 메모리 관리가 깔끔하고 캐시 상태가 항상 최신 데이터로 유지가 된다는 장점이 있습니다. 하지만 주기적으로 검사를 하기 때문에 시스템 리소스를 소비하며 특히 캐시된 데이터의 크기가 큰 경우 오버헤드가 상당할 수 있습니다.

지연 삭제(Lazy deletion)

이 방식은 데이터에 접근할 때만 만료 여부를 확인합니다. 이 방식의 장점은 적극적 삭제와 달리 필요할 경우에만 삭제 작업이 이루어지므로 삭제 작업에 따른 시스템 부하를 분산시킬 수 있다는 장점이 있습니다. 하지만 이 방법의 단점은 만료된 데이터가 즉시 삭제가 되지 않고 불필요하게 메모리를 차지하고 있을 수 있다는 단점이 존재합니다.
TTL 캐시의 장점은 구현이 단순하고 예측 가능하다는 것입니다. 데이터가 언제 만료될지 정확히 알 수 있어서 캐시 관리가 용이합니다. 하지만 TTL을 얼마로 설정할지는 까다로운 문제입니다. 너무 짧게 설정하면 캐시의 이점을 충분히 활용하지 못하고, 너무 길게 설정하면 오래된 데이터를 계속 사용하게 될 수 있습니다. 다음은 어떻게 TTL 시간을 적절하게 설정할 수 있을 지에 대해서 다루어 보겠습니다.

TTL 값 설정은 어떻게 설정해야 할까?

TTL 캐시를 사용할 경우에는 데이터의 특성을 잘 파악해야 합니다. 캐싱이 필요한 데이터가 장기간 유지가 될 필요가 있는 데이터일지 아니면 일시적으로 유지되어도 이상이 없는 데이터인지 데이터를 사용하는 서비스의 특징과 함께 고려하여 해당 시간을 설정해야 합니다. TTL 설정 시 고려해야하는 보편적인 특징에 대해서 아래의 리스트로 나열 해보겠습니다.
  • 데이터 변경 빈도 분석
    • 자주 변경되는 데이터 (예: 주식 가격): 수초~수분
    • 중간 정도로 변경되는 데이터 (예: 상품 재고): 수분~수시간
    • 거의 변경되지 않는 데이터 (예: 제품 기본 정보): 수시간~수일
  • 시스템 부하 고려
    • 원본 데이터 생성/조회 비용이 높을수록 TTL을 길게 설정
    • 캐시 저장소의 용량이 제한적이라면 TTL을 짧게 설정
    • 피크 시간대에는 더 긴 TTL을 사용하여 원본 시스템 부하 감소
  • 데이터 일관성 요구사항
    • 강한 일관성이 필요한 경우: 매우 짧은 TTL 또는 캐시 사용 지양
    • 최종 일관성으로 충분한 경우: 비즈니스 요구사항에 따라 적절히 설정
  • 사용자 경험 최적화
    • 페이지 로딩 시간이 중요한 경우: 더 긴 TTL 설정
    • 실시간 데이터가 중요한 경우: 더 짧은 TTL 설정
실제 구현 시에는 이러한 요소들을 종합적으로 고려하여 TTL을 설정하고, 모니터링을 통해 캐시 적중률(hit ratio)과 시스템 성능을 관찰하면서 점진적으로 최적화하는 것이 좋습니다.

1. FIFI(First In First Out) 캐시

가장 단순한 캐싱 방식 중 하나로, 먼저 들어온 데이터가 먼저 제거되는 단순한 캐싱 방법입니다. 이 캐싱 방법은 구현과 관리하기에 용이하다는 특징이 있지만 캐싱이 되어 있는 데이터가 어떠한 데이터인지 고려하지 않고 단순히 오래 되었다는 이유만으로 제거될 수 있는 위험성이 존재합니다.

2. MRU(Most Recently Used) 캐시

가장 최근에 사용된 항목을 제거합니다. 특이한 접근 패턴을 가진 워크로드에서 유용할 수 있습니다. 예를 들어, 데이터베이스의 full table scan과 같이 한 번 접근한 데이터는 당분간 다시 접근하지 않을 것이 확실한 경우에 효과적입니다.

3. LFU(Least Frequently Used) 캐시

캐시되어 있는 데이터의 각 항목의 접근 빈도를 계산하여 가장 적게 접근한 횟수의 항목을 제거합니다. 이는 장기적인 인기도를 반영할 수 있지만 최근에 추가된 새로운 인기 항목이 낮은 접근 횟수 때문에 제거될 수 있다는 단점이 있습니다.

마무리

블로그의 성능을 개선하기 위해서는 우선적으로 Idle 시간Rendering 시간을 어떻게 줄일 수 있을지에 대해 구체적으로 살펴봐야 합니다. 개발자 도구의 Performance 패널을 통해서 Idle 상태가 길어지는 상황을 분석하고, 리소스 로딩과 이벤트 핸들링, DOM 구조 복잡성 등에 의해서 발생하는 지연 요소를 체계적으로 파악하는 과정이 중요합니다. 특히 비동기 처리가 적절히 이루어지지 않아서 동기적으로 파일을 읽거나, DOM의 깊은 중첩 구조가 불필요하게 복잡해졌다면 해당 부분들을 우선적으로 리팩터링해야 합니다.
동시에, 데이터 중복 조회반복 작업으로 인해 성능이 저하되지 않도록 캐싱(Cache) 전략을 도입하는 것이 효과적입니다. 캐시는 한 번 로드한 데이터를 재사용하므로 서버나 디스크 I/O 부하를 줄이고, 결과적으로 페이지 전환 속도를 향상시켜 줍니다.
  • TTL(Time To Live) 캐시 캐시된 데이터에 유효 기간을 부여하여 일정 시간이 지나면 데이터를 폐기하거나 갱신하는 방식입니다. 데이터 변경 주기나 시스템 부하 등을 고려하여 적절한 TTL을 설정하는 것이 핵심입니다.
  • FIFO, LRU, MRU, LFU 등의 캐싱 알고리즘 어떤 데이터를 우선적으로 캐시하고, 언제 제거할지를 결정하는 다양한 방법들이 있으며, 각 알고리즘별로 특성과 적합한 사용 사례가 다릅니다.
단순히 어떤 캐싱 방식을 선택하느냐보다, 어떤 데이터가 자주 접근되는지(인지도), 실시간성이 얼마나 필요한지(일관성), 어느 정도까지 오래된 데이터를 허용할 수 있는지(정확성) 등을 종합적으로 고려해야 합니다. 이 과정을 통해 블로그 특성에 맞는 최적의 캐싱 방식을 적용할 수 있습니다.
결국, 사용자의 시각에서 느끼는 반응 속도가 중요합니다. 무작정 Idle 시간이 길거나 짧다고 해서 무조건 성능이 나쁘거나 좋은 것이 아니라, 전체적인 페이지 전환이 사용자가 지연을 느끼기 전에 진행되는지가 핵심 지표가 됩니다. 따라서, 정확한 모니터링 및 분석 도구를 통해 성능 상태를 체크하고, 캐시 설계와 비동기 처리를 개선해나가면서 지속적으로 최적화하는 것이 바람직합니다.
이러한 과정을 거치면 페이지 전환 속도렌더링 속도 모두에서 확실한 체감 성능 향상을 기대할 수 있습니다. 앞으로 블로그가 확장되거나, 새로운 기능이 추가될 때도 마찬가지 원칙(모니터링, 비동기 처리, 캐싱 전략)을 토대로 일관적인 퍼포먼스를 유지한다면, 사용자가 쾌적하게 정보를 탐색할 수 있는 블로그 환경을 만들어 나갈 수 있을 것입니다.