안녕하세요, 사이드 프로젝트로 알바 공고글 노출 서비스를 운영하고 있는 프론트엔드 개발자입니다. 이 서비스는 Next.js 14 App Route를 기반으로 개발되었으며, AWS Amplify를 통해 배포되고 있습니다.
서비스를 운영하면서 페이지 전환 과정에서 성능 문제가 발생하는 상황을 겪었습니다. 이는 사용자 경험에 직결되는 문제이기 때문에 이를 해결하기 위해 다양한 시도를 하며 개선 작업을 진행했습니다.
이 글에서는 성능 문제를 해결하기 위해 시도한 방법들과 그 과정에서 겪은 시행착오를 공유하고자 합니다. 비슷한 문제를 고민하는 분들에게 참고가 될 수 있기를 바랍니다.
알바 공고 리스트에서 특정 공고를 클릭하면, Next.js 서버 컴포넌트를 활용하여 해당 공고의 상세 데이터를 API로 호출하고 데이터를 기반으로 화면을 렌더링해 유저에게 제공합니다. 그러나 이 과정에서 데이터를 가져오는 데 최대 1초 이상의 지연이 발생하는 경우가 있었습니다.
1초라는 시간이 짧게 느껴질 수도 있지만, 화면이 잠시 멈춘 것처럼 보이기 때문에 이는 UX적으로 큰 이슈라고 생각했습니다.
(움짤이라 더 느려 보이네요..)
데이터를 가져오는 데 걸린 실제 시간은 약 600ms(0.6초)로, 비교적 긴 시간이 소요된 것을 확인할 수 있었습니다.
서비스의 주요 사용자가 베트남 지역에 거주하는 점을 고려했을 때, 한국보다 네트워크 환경이 상대적으로 제한적일 가능성이 높습니다. 따라서 이 성능 문제는 반드시 해결해야 할 과제라고 판단했습니다.
우선 기존 코드는
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { urlSlug } = params
const response = await fetch(`서버 주소/jobs/${urlSlug}`, {
cache: 'no-store'
})
...
return { ... }
}
export default async function JobPage({ params }: Props) {
...
const response = await fetch(`서버 주소/jobs/${urlSlug}`, {
cache: 'no-store'
})
...
}
Request Memoization가 적용되어 Request를 한번만 호출하겠지만 페이지를 부를 때 매번 API를 호출합니다. 조회수와 좋아요 수가 실시간으로 반영하기 위해 캐싱을 의도적으로 막아둔 상태였기 때문입니다. 조회수와 좋아요 수가 즉각적으로 반영되지 않으면 사용자 입장에서 버그처럼 보일 수 있다는 기획 측 의견 때문이었습니다.
하지만 서비스의 핵심은 공고의 내용이지, 조회수와 좋아요 수가 아닙니다. 캐싱을 막아 페이지 로딩 속도를 포기하는 것은 좋지 않은 선택이었습니다.
결국 논의 끝에, 캐싱은 유지하되 조회수와 좋아요 수를 별도의 API로 분리해 Client Side에서 호출하는 방식으로 개선했습니다.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { urlSlug } = params
const response = await fetch(`서버 주소/jobs/${urlSlug}`, {
next: { revalidate: 3600 * 24 }
})
...
return { ... }
}
export default async function JobPage({ params }: Props) {
...
const response = await fetch(`서버 주소/jobs/${urlSlug}`, {
next: { revalidate: 3600 * 24 }
})
...
}
캐시 유효기간은 3600 * 24로 하루로 설정했습니다. 공고 내용은 자주 변경되지 않는 특성이 있기 때문에, 하루 동안 캐시된 데이터를 보여주는 것으로 충분하다고 판단했습니다. 설령 공고가 변경되더라도 하루 이내에만 반영되면 큰 문제가 없다는 합의가 있었습니다.
위와 같이 코드를 수정하면 Data cache가 되는데 build 후 start를 돌리면 .next/cache/fetch-cache에 해당 캐시 정보가 저장되는 것을 확인할 수 있습니다. 이를 통해 이후 동일한 요청에서는 캐시된 정보를 활용하여 더 빠르게 데이터를 가져올 수 있을 것이라 기대했습니다.
하지만 이것만으로는 효과는 미미했습니다...
API 호출 속도 자체는 예상보다 오래 걸리지 않았던 것으로 보입니다. 물론 평균적인 로딩 속도는 개선되었고, API 호출 횟수를 줄일 수 있었다는 점은 의미 있는 성과였습니다. 하지만 여전히 성능을 더욱 효과적으로 개선하기 위해 추가적인 노력이 필요하다고 판단했습니다.
이제, 실제로 더 나은 성능 개선을 이루기 위해 다음 스텝을 고민해보았습니다.
Full Route Cache는 RSC Payload와 HTML을 미리 생성해두고 이를 serving하여 확실한 속도 개선을 기대해볼 수 있습니다.
이미 Data Cache를 적용해놨기 때문에 Full Route Cache는 바로 적용해볼 수 있습니다.
export const revalidate = 86400
export const dynamic = 'force-static'
이 코드만 추가해주면 full route cache를 적용할 수 있습니다.
모든 경로를 처음 방문한 후에는 정적으로 렌더를 할 수 있습니다.
(여기에서 dynamic 옵션을 더 알아볼 수 있습니다.)
이번에는 실제로 유의미한 성과를 얻을 수 있습니다. RSC Payload를 prefetch해오기 때문에 사실상 지연속도가 0초라고 볼 수 있었습니다.
prefetch된 RSC Payload를 가져오는 속도는 14ms(Local환경 기준)로 상당히 개선되었습니다.
위와 같이 캐시된 데이터의 로딩 속도가 14ms, 13ms로 매우 빠르게 나오는 상황에서는, prefetch 없이 페이지를 불러와도 지연을 체감하기 어렵다고 판단했습니다.
오히려 prefetch를 사용할 경우, 최대 20개의 페이지를 한 번에 로드하면서 메모리를 불필요하게 소비하게 됩니다. 평균 데이터 크기가 3.6kb이고, 이를 20개 페이지로 계산하면 약 60kb를 낭비하게 됩니다. 물론 사용자가 실제로 prefetch된 모든 페이지를 방문한다면 가져오는 데이터 크기는 같겠지만, 현실적으로 그럴 가능성은 낮기 때문에 비효율적이라 보았습니다.
이를 해결하기 위해 아래와 같이 prefetch 옵션을 비활성화했습니다:
<Link href={`/job/${urlSlug}`} prefetch={false}>
...
</Link>
이 변경 후 로컬 환경에서 테스트한 결과, 지연이 체감되지 않았고 문제없이 정상적으로 작동하는 것을 확인했습니다.
여태까지 아주 수월하게 문제를 해결했습니다. 바로 Dev서버에 배포하러 갔습니다.
하지만... 진짜 문제들은 이것을 Amplify에 배포하면서 발생했습니다
Amplify로 배포하면, 빌드된 파일들은 S3 버킷에 업로드됩니다. 이후 사용자가 요청을 보내면 CloudFront가 캐시된 콘텐츠를 확인하고, CDN(Content Delivery Network) 역할을 수행합니다. 캐시가 없을 경우, 서버리스 컴퓨팅 환경인 Lambda에서 JavaScript 파일을 실행해 동적으로 처리한 뒤, 최종적으로 해당 파일을 사용자에게 Serving합니다.
Amplify에 배포한 Next.js App Route 기반 프로젝트에서 CloudFront와 Next.js 모두 캐싱이 제대로 작동하지 않는 문제를 확인했습니다.
CloudFront 응답 헤더와 Next.js 캐시 상태를 보면, X-Cache: Miss from CloudFront
, X-Nextjs-Cache: MISS
로 표시되며, 캐싱이 이루어지지 않았습니다.
로컬 환경에서는 동일한 설정으로 X-Nextjs-Cache: HIT
가 정상적으로 표시되었고, Cache-Control 헤더도 s-maxage=86400, stale-while-revalidate
로 설정되어 있었습니다.
하지만 Amplify에 배포한 서버의 응답 헤더를 확인해보니 Cache-Control: no-store로 설정되어 있었으며, 캐싱이 작동하지 않는 원인이었습니다.
![]() | ![]() |
---|---|
Amplify 배포 환경 | Local 환경 |
AWS 공식 문서에서 보면 프레임워크 내에서 동적 경로에 대해 설정된 Cache-Control 헤더를 준수한다고 했으나 실제로는 적용이 되지 않았던 겁니다. next.config.js에서도 Cache-Control을 명시해줘도 똑같았습니다...
혹시나 해서 next.js page route로 프로젝트를 생성하고 테스트해보니 여기서는 no-store가 아니라 정상적으로 설정해준 Cache-Control이 나오고 있습니다. 즉, Page Route에서는 정상적으로 동작합니다.
// page route로 테스트 하기 위한 소스 코드
// 출처: https://github.com/zenver6/amplify-nextjs-isr-sample
type Props = { timestamp: number };
export default function ISR(props: Props) {
return (
<div className={styles.container}>
<main className={styles.main}>
<h1 className={styles.title}>ISR적용</h1>
<p> time: {props.timestamp}</p>
</main>
</div>
);
}
export const getStaticProps: GetStaticProps<Props> = async (context) => {
return {
props: {
timestamp: new Date().getTime(),
},
revalidate: 60,
};
};
아무래도 Amplify에서 app route로 하면 발생하는 버그가 아닐까 생각합니다
아무튼 문제를 해결하기 위해서는 명시적으로 customHttp.yml
을 작성해 주어야 했습니다.
customHeaders:
- pattern: /job/*
headers:
- key: Cache-Control
value: s-maxage=86400, stale-while-revalidate
작성을 하고 다시 배포를 하니
이제는 X-Cache가 Hit from cloudfront로 CloudFront에서 캐시가 되고 그걸 넘겨주는 것을 확인할 수 있었습니다.
위에 사진에서 X-Nextjs-Cache가 MISS로 나오는 것은 어찌 보면 당연한 결과입니다. 캐시된 값이 없을 때, Origin 서버(Next.js 서버)에서 MISS로 응답했고, 이 값을 CloudFront가 캐싱하여 이후 요청에 대해 제공하기 때문입니다. 이 동작 자체는 정상입니다.
그러나 문제가 되는 경우는, X-Amz-Cf-Pop이 다른 Edge 위치에서도 여전히 X-Nextjs-Cache: MISS로 응답이 오는 상황입니다.
CloudFront는 특정 Edge 위치에서 Origin 서버로부터 데이터를 가져왔다면, 동일한 요청이 다른 Edge 위치에서 발생하더라도 Origin 서버에서는 이미 캐시를 하고 있는 상태이기 때문에 X-Nextjs-Cache: HIT으로 나와야 합니다. 하지만
x-amz-cf-pop는 요청을 처리한 CloudFront 엣지 로케이션을 나타냅니다. 즉, 지역 및 환경 따라 다를 수 있습니다. https://www.feitsui.com/en/article/3 이곳에서 각 코드를 확인할 수 있습니다.
VPN을 이용해 CloudFront의 Edge Location을 변경한 뒤 다시 요청을 보내 보았습니다.
(테스트는 모바일 환경에서 Safari 브라우저를 통해 진행했습니다.)
결과적으로, Edge Location이 변경될 때마다 CloudFront와 Next.js 모두에서 MISS가 발생했습니다. 이는 새로운 Edge에서 요청이 들어올 때마다 Origin 서버로 다시 데이터를 가져오는 현상을 보여줍니다. 이 문제는 VPN을 통해 여러 Edge Location으로 테스트했을 때도 동일하게 나타났으며, 캐싱이 제대로 작동하지 않는 상황을 확인할 수 있었습니다.
하지만 특이한 점은, 엣지로케이션을 바꾸다보면 아주 가끔 X-Nextjs-Cache: HIT로 응답이 반환되는 경우도 있다는 것입니다.
주의) 아래 내용은 관련 자료가 없는 관계로 혼자 분석하여 추측이 많이 들어가있습니다
Amplify는 결국 Lambda를 통해 동작합니다. 항상 하나의 서버가 계속 실행되는 것이 아니라, 요청 상황에 따라 새로운 Lambda가 생성되기도 하고, 이미 실행 중인 Lambda가 재활용되기도 합니다. Lambda는 /tmp 디렉토리를 통해 함수를 위한 임시 스토리지를 제공합니다. 하지만 이 스토리지는 Lambda가 종료되면 사라지는 데이터이기 때문에 공유할 수 없는 스토리지입니다.
문제는 Full Route Cache를 통해 생성된 데이터와 페이지가 이 임시 스토리지(/tmp)에 저장되고 있는 것 같습니다. 결과적으로, 요청 시 새로운 Lambda가 실행되면 캐시된 데이터가 없기 때문에 MISS로 처리되고, 우연히 동일한 Lambda 인스턴스가 재활용되었을 경우에만 HIT로 처리되는 현상이 발생하는 것으로 보입니다.
이로 인해 Edge Location을 변경하며 요청을 보낼 때, 새로운 Lambda가 생성된 경우에는 MISS, 동일한 Lambda 인스턴스가 재활용된 경우에는 HIT가 발생하는 비일관적인 캐싱 동작이 나타납니다.
Amplify에서 ISR을 제공한다고 적혀있긴 하지만, 실제로 제대로 적용이 되는 것은 아닌 것 같습니다. Cloudfront를 이용해 ISR이 적용되는 것 처럼 보이는 것이었죠. (Page route에서도 테스트 해봤으나 똑같습니다.)
결론적으로 이 문제를 해결할 방법은 딱히 없는 것으로 보입니다. 다만 유저가 이용할 때 페이지 전환 속도를 개선하기 위해서 prefetch를 다시 켜주면 낭비되는 데이터는 있어도 페이지 전환 속도는 다시 개선할 수 있었습니다. (따지고 보면 60kb정도는 괜찮지 않을까..)
사람들이 Next.js가 Vercel 배포환경에만 너무 친화적이다 라고 욕할 때 크게 공감을 하지 못했습니다. EC2같은 컴퓨팅으로는 당연히 큰 문제가 없고 Amplify로 배포할 때도 정말 쉽게 배포가 가능하고 문제도 없어 보였기 때문이죠. 하지만 이번 과정을 통해 많은 버그들을 보았고 (근데 이건 Next.js 문제라기보단 Amplify 문제 같긴 하네요) 우선은 계속 Amplify를 사용하지만 사용자가 더 많아지면서 나중에 문제가 된다면 Vercel로 바꾸거나 Amplify를 버리고 다른 대안을 찾아봐야 할 것 같습니다.
이 글을 통해 저랑 같은 문제를 겪으신 분들이 삽질을 줄일 수 있으면 좋겠습니다.
글 읽어주셔서 감사합니다!