작년에 두 개의 SvelteKit, Strapi CMS로 이루어진 프로젝트에서 타입 안정성을 만들고, any로 점철되어 있던 SvelteKit쪽의 코드들에 타입을 붙이는 작업을 한 적이 있었는데요, 해당 사이트에 그동안 쌓여 있던 300여 명의 작가 데이터가 업로드되자 구조적인 문제가 드러났습니다. 바로 “이전 작가” 페이지였는데요, 총 페이지 로드 시간이 너무 길어진 것입니다. “이전 작가” 페이지에 내려오는 데이터는 gzip 압축 기준 454KB, 압축이 풀리면 3.07MB였습니다.
상황이 이렇다 보니 운영사 측에서는 속도 개선을 요청했고, 이번 유지보수에서는 이 사이트에서 잠재적으로 꼭 필요한 것들과 함께 이 성능 개선을 하기로 협의되었습니다.
“이전 작가” 페이지의 모습은 아래와 같습니다. (2025/2026작가 페이지도 구성은 같습니다)
페이지의 구현을 살펴보면 작가 페이지에 모든 작가에 대한 정보가 한번에 SSR되고, 클라이언트에서 그 데이터를 모두 가지고 있다가 작동하는 식이었습니다. 라우트 이동에 따라 데이터를 새로 가져오지는 않고, 클라이언트에서 렌더링하는 데이터만 바뀌고, 모든 작가의 상세 데이터까지 모두 가지고 있어야 하는 상태로 구현되어 있었습니다.
SvelteKit 서버는 이 페이지 요청을 받으면 Strapi 서버에 다음과 같이 요청하도록 설계되어 있었습니다. 코드로 표현하면 아래와 같은데요,
strapi.find('authors', {
pageSize: 1000, // 모든 작가 데이터 필요
populate: {
featured_works: { populate: 'media' }, // 작가마다 작품 및 이미지 전체
projects: ... // 선정연도 그룹과의 관계
}
})실제 Postgres에서는 아래처럼 쿼리되는 것을 확인할 수 있었습니다. Strapi가 아래처럼 쿼리를 묶는데요,
SELECT * FROM authors
WHERE published_at IS NOT NULL
ORDER BY name LIMIT 1000; -- 작가 전체 통째로 (실제 약 300명)
-- 작가별 컴포넌트 링크 (featured_works)
SELECT * FROM authors_components
WHERE author_id IN (...작가 전체...) AND field='featured_works' ORDER BY "order";
-- 컴포넌트 링크에서 작품 찾기
SELECT * FROM components_author_artworks WHERE id IN (...작가*작품 수...);
-- 미디어 morph (다형성 → 가장 비싼 단계)
SELECT * FROM files_related_morphs
WHERE related_type='components_author_artworks'
AND field='media' AND related_id IN (...작가*작품...);
-- 파일 찾기
SELECT * FROM files WHERE id IN (...모든 작품 이미지...);
-- 선정연도와 링크
SELECT * FROM authors_projects_links WHERE author_id IN (...);
SELECT * FROM projects WHERE id IN (...);작가명 → 컴포넌트 → 미디어 → 작품 파일 링크로 관계 깊이가 생기는데, 모든 행에서 SELECT * 을 하고 있기 때문에 쿼리 효율이 매우 나쁩니다.
더불어 프론트엔드의 잘못된 렌더 전략 설계가 그대로 DB 쿼리로 이어진 형태를 확인할 수 있습니다. 작가 상세 페이지의 경우 리스트에서 받은 데이터 중 하나를 매치시켜 그대로 렌더링하는 식으로 작동하고 있었기 때문입니다. 즉 작가 상세 페이지로 진입하는 경우에도 사용자는 다른 300여명의 모든 작가 정보도 가지고 있어야 합니다.
이 문제는 쿼리 개선을 통해 일부 해결할 수 있습니다. 상세 페이지에 해당하는 상세 데이터를 화면에 맞게 축소하는 것입니다.
그래서 상세 데이터를 제외하고 리스트용 데이터만 축소 요청해 제공받고, 상세 페이지는 따로 데이터를 제공받도록 1차 개선을 진행했습니다. 또, SWR을 모방해 타임스탬프 기반 캐시 레이어도 앱 내에 직접 구현했습니다. 이 개선을 통해 아래와 같은 성과를 얻었습니다:
| 지표 | 개선 전 | 쿼리 개선 |
|---|---|---|
| 성능 점수 | 68 | 74 |
| FCP(초) | 2.0 | 1.9 |
| LCP(초) | 3.1 | 2.8 |
| Speed Index(초) | 3.2 | 1.9 |
| TTFB(ms) | 670 | 46 |
이런 개선을 진행했지만 클라이언트 로드 속도는 드라마틱하게 나아지지 않았는데요, 사실 쿼리 개선은 아래의 마이그레이션 작업을 실행하기 위한 빌드업이었습니다.
아키텍처 구조의 개선 필요
페이지 단위의 강력한 캐시 필요 (ISR / SSG)
이 프로젝트의 배포 구조와 마이그레이션 이후의 배포 구조는 아래와 같습니다:
가장 큰 문제는 SvelteKit과 Strapi CMS가 서로 다른 프로세스여서, 한 번의 요청에 최대 2번의 HTTP 통신이 발생해야 한다는 것입니다. 이에 불필요한 통신 구조를 없애는 것이 장기적으로 볼 때 여러 면에서 편리할 것이라고 판단했습니다.
ISR을 쓰기에 최적인 상황 → Next.js
작가 리스트 페이지는 일 년에 한 번 메이저 업데이트가 되고, 페이지네이션이 없습니다. 즉 모든 작가 리스트 데이터를 가지고 있어야 합니다. 여기에 관리 툴에서 자잘한 수정이 많이 이루어질 예정이었습니다.
Next.js는 자체 호스팅 환경에서도 ISR을 지원합니다. SvelteKit은 2026년 6월 현재 서버리스 환경에서만 가능합니다.
Payload CMS와 긴밀하게 통합되어 CMS 사용 시, 이전에 비해 압도적인 속도를 보여줍니다.
리스트 페이지의 HTTP 통신 횟수가 캐시 히트 시 0회, 최대 1회로 감소함
Next.js의 강력한 캐싱 기능을 사용할 수 있었으며, 페이지별로 다른 캐시 전략 적용 가능
사실상 같은 코드베이스의 SvelteKit 앱 두 개가 완전히 분리되어 관리되고 있던 것을 하나로 통합해, 유지보수 및 관리 복잡도가 많이 하락했습니다.
게다가 CMS 통합으로 다른 앱을 참조해야 하던 타입 호환을 프로젝트 하나에서 자동 관리할 수 있게 되었고, 무엇보다 3개의 컨테이너를 하나로 합친 것이기 때문에 컴퓨팅 자원을 절감할 수 있었습니다. (곧 인스턴스 비용 절감으로 이어집니다)
ISR를 적용하면 프리렌더된 HTML을 캐시에서 바로 서빙 및 갱신하게 되고, 아래와 같은 결과를 측정할 수 있었습니다:
| 지표 | 개선 전 | 쿼리만 개선 | Next.js (ISR) |
|---|---|---|---|
| 성능 점수 | 68 | 74 | 94 |
| FCP(초) | 2.0 | 1.9 | 0.7 |
| LCP(초) | 3.1 | 2.8 | 1.6 |
| Speed Index(초) | 3.2 | 1.9 | 0.7 |
| TTFB(ms) | 670 | 46 | 24 |
작년에 이 프로젝트를 맡게 된 이후 계속 염두에 두고 있던 문제였는데, 첫 해에는 여러 개발 외적인 사건들과 내부적인 개발 안정성 문제를 먼저 해결하느라 마이그레이션 작업을 제안하지 못했었습니다. 올해에는 계약과 함께 관련 요청을 주시면서 개발 공수를 많이 투자할 수 있었고 덕분에 6월 17일 현재에는 승인만을 기다리고 있습니다.
성능 문제는 단순히 쿼리나 프레임워크 하나의 문제가 아니었습니다. 데이터 조회량이 최초 원인처럼 보였지만, 그것은 부차적일 뿐 실질적으로는 렌더링 전략과 배포 구조, CMS와의 통신 방식이 모두 연결된 문제였다고 생각합니다.
결과적으로 이번 개선은 쿼리 최적화보다도 아키텍처를 현재 요구사항에 맞게 재설계한 작업에 가까웠습니다. SvelteKit은 개인적으로 좋은 개발 경험과 빌드 타임에 렌더 문제를 해결한다는 합리적인 설계 철학을 보여주었지만, 자체 호스팅 환경에서의 빈약한 캐싱 기능과 CMS를 백엔드로 쓰는 상황에서 Next.js가 더 좋은 퍼포먼스를 낸다는 것이 결정적이었습니다.
또 풀스택을 다루면서 잘못된 프론트엔드 렌더링 설계가 그대로 DB 쿼리까지 이어져 있는 상황을 접하게 됐고, 이 문제를 하나하나 해결해 보는 귀중한 경험을 할 수 있었습니다. 프론트엔드 설계 자체도 문제였지만 풀스택 프로젝트를 다룰 때, 프론트엔드 설계를 그대로 백엔드 너머까지 끌고가지 않도록 의식적으로 사고를 분리할 필요가 있겠다는 생각도 했습니다.