🐞 검색엔진 타임아웃 문제 해결 - Sitemap 성능 최적화
[문제 요약]
증상: 검색엔진 봇(Yeti)이 sitemap 요청 시 Connection timed out 오류 발생
원인: sitemap을 요청 시 기존 목록 API를 페이지 단위로 순회 호출하여 응답 지연 발생
해결: 각 도메인 별로 /sitemap 엔드 포인트 추가 및 ISR 주기 변경
프론트엔드(Next.js) 서버에서 sitemap을 생성할 때 백엔드(Spring Boot) API를 통해 글 목록과 카테고리 목록을 받아오는 기존 구조에서는 전체 데이터를 한 번에 불러오지 않고 페이지 단위로 순차 요청을 보내다 보니 글 수가 많아질수록 응답 속도가 지연되는 문제가 발생하였다.
Googlebot의 경우 요청 시간이 다소 길더라도 기다리는 편이지만 네이버 검색엔진 봇(Yeti)은 일정 시간 이상 응답이 없을 경우 요청을 끊어버리는 것으로 확인되었다.
해결 방법 1) sitemap을 SSG로 생성
기존 SSR 방식으로 생성하던 sitemap을 SSG로 전환하여 빌드 시 sitemap 생성
import { MetadataRoute } from "next";
// export const dynamic = 'force-dynamic'; 기존 SSR 설정
export const dynamic = 'force-static'; // SSG 방식으로 전환
이 방법으로 응답속도는 크게 줄일 수 있었지만 새로운 글이 추가되어 sitemap에 추가하기 위해서는 전체 프로젝트를 재빌드해야 하여 실시간 반영 측면에서 유연성이 떨어졌다.
해결 방법 2) 신규 엔드 포인트 추가
처음에는 sitemap 생성을 위해 별도의 전용 컨트롤러를 만드는 것이 과도하게 느껴져 기존 게시글 목록 API를 그대로 활용하였다.
그러나 페이징 기반 API이다 보니 전체 데이터를 순차적으로 여러 번 호출해야 했고 본문 같은 sitemap에 불필요한 필드까지 포함되어 오버헤드가 크고 글 수가 늘어날수록 응답 속도가 급증하였다.
따라서 근본적인 해결을 위해 기존 도메인에 /sitemap 엔드 포인트를 추가하였다.
@Query("""
SELECT new com.pyomin.cool.dto.SitemapDto(
p.slug,
COALESCE(p.updatedAt, p.createdAt)
)
FROM Post p
WHERE p.status = :status
ORDER BY COALESCE(p.updatedAt, p.createdAt) DESC, p.id DESC
""")
List<SitemapDto<String>> findAllForSitemap(PostStatus status);
sitemap 생성에 필요한 데이터만 최소한으로 조회하도록 DTO 프로젝션을 사용하였고 lastmod 설정을 위해 COALESCE()를 사용하여 수정일이 없으면 생성일을 조회하도록 처리하였다.
SitemapDto는 아래와 같이 설계하였다.
package com.pyomin.cool.dto;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
public record SitemapDto<K>(K key, Instant lastModified) {
public SitemapDto(K key, LocalDateTime lastModifiedLdt) {
this(
key,
lastModifiedLdt == null
? null
: lastModifiedLdt.atZone(ZoneId.of("Asia/Seoul")).toInstant()
);
}
}
문자열 slug뿐만 아니라 추후에 다른 타입의 식별자도 포함할 수 있도록 제네릭으로 설계하였으며 sitemap 표준 필드인 <lastmod>는 UTC 기반 ISO 8601 포맷을 요구하기 때문에 DB에서 조회한 LocalDateTime을 그대로 사용하지 않고 생성자를 통해 Asia/Seoul 타임존을 지정한 뒤 Instant로 변환하여 타임존에 독립적인 값을 보관하도록 설계하였다.
이후 카테고리도 같은 방식으로 엔드 포인트를 설계한 후 프론트에서 Promise.all()을 사용하여 동시 호출을 통해 응답 지연을 최소화하고 ISR 주기를 24시간으로 설정하였다.
import { MetadataRoute } from "next";
import { getApiBase } from "@/lib/api/apiBase";
export const revalidate = 86400; // ISR (하루마다 재생성)
const SITE_URL = 'https://pyomin.com';
type SlugSitemap = { slug: string; lastModified: string };
type SitemapResponse<T> = { sitemap: T[] };
async function fetchPostSitemap(): Promise<SlugSitemap[]> {
const res = await fetch(`${getApiBase()}/posts/sitemap`);
if (!res.ok) throw new Error('posts/sitemap 실패');
const json = (await res.json()) as SitemapResponse<SlugSitemap>;
return json.sitemap ?? [];
}
// 카테고리 fetch 생략
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [posts, categories] = await Promise.all([fetchPostSitemap(), fetchCategorySitemap()]); // 동시 호출
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${SITE_URL}/posts/${encodeURIComponent(post.slug)}`,
lastModified: post.lastModified,
changeFrequency: 'monthly',
priority: 0.8,
}));
// 이 외 사이트맵 생성 및 return
}
결과적으로 sitemap 응답 속도를 1초 이내로 줄이는 동시에 검색엔진이 요구하는 표준 포맷을 지킬 수 있게 되었다.
