티스토리 뷰
Nextjs의 app router로 진행했던 개인 프로젝트를 다시 배포했다.
이번 프로젝트를 하면서 가장 신경을 썼던 부분은 로딩, 랜더링 성능의 최적화이다.
클라이언트 컴포넌트를 어디에 둬야 트리의 가장 하단에서 인터렉션에 따른 리랜더링을 최소화할 수 있을까?
어떻게 유저에게 빨리 보여주고 덜 지루하게 할 수 있을까?
이러한 고민들의 한 방법으로 컴포넌트 스트리밍과 Blur 이미지 제공하는 기능을 추가했다.
하지만 로컬에서와 다르게 배포환경에서 제대로 동작하지 않는 부분에서 트러블이 있었고,
이 글에서 그 트러블과 해결을 정리한다.
MainPosts컴포넌트에 데이터를 fetch 요청하는 로직이 들어있다.
loading.tsx파일을 준비하거나(로딩화면) 스트리밍 fallback ui를 준비하지 않으면 저 mainposts의 데이터 패치가 오래 걸리는 만큼 유저에게 보이는 첫 화면이 딜레이 된다. 이 컴포넌트는 치명적이라고 생각했던 이유가. 홈('/') 화면이기 때문에 url을 누르는 즉시 뭐라도 보이길 바랐다. 괜히 저 api가 느려지면 url 누르자마자 못 들어오고 브라우저 탭의 로딩을 봐야 한다.
그래서 스트리밍을 적용해서 fallbackui를 넣었다.
문제는 ec2에만 올라가면 이 부분이 동작을 안 해서 첫 화면이 뜨는데 3초 이상 걸린다는 것이다.
첫 번째 문제는 프론트엔드 서버컴포넌트의 data fetch가 3초나 걸린다는 것,
두 번째, fetch가 3초나 걸리는 건 그렇다 치고, 스트리밍이 안되어 fallback ui도 안 보인다는 것.
1. 스트리밍 안됨
로컬에서 실행하면 잘되는데,,, 혹시 내가 프로덕션 도커 이미지를 만드는 과정에서 문제가 생겼나? 싶어서 도커이미지를 올려봤지만 도커이미지도 문제없이 실행됨. 그렇다면 ec2의 문제일까? 하던 중에
Stream Functionality Issues with Next.js Deployed in a Docker Container
Stream Functionality Issues with Next.js Deployed in a Docker Container · Issue #215 · vercel/ai
Hello, I've been encountering some issues with the streaming functionality of Next.js OpenAIStream when it's deployed in a Docker container. The streaming feature works perfectly in a localhost env...
github.com
이슈를 발견했고, 내가 겪는 문제의 원인은 nginx를 통한 리버스프록시 설정 문제임을 알게 되었다.
nginx가 ec2와 클라이언트 사이에서 웹서버 역할을 하면서 클라이언트의 요청과 서버의 응답을 버퍼공간에 저장하면서
성능을 최적화한다.
nginx는 다수의 클라이언트에 응답을 효율적으로 처리하기 위해 비동기 이벤트 기반 아키텍처로 만들어져 있다.
클라이언트 커넥션 한 개마다 한 개의 스레드를 생성하는 다른 아키텍처와 달리 한개의 쓰레드에서 비동기 이벤트 방식으로 여러
커넥션을 처리할 수 있다. 그래서 대규모 클라이언트의 웹서버 역할을 하는데 훨씬 효율적이라고 한다.
어떻게 소수의 쓰레드를 사용하면서도 커넥션마다 블로킹 없이 비동기 방식으로 요청, 응답을 할 수 있을까?
바로 버퍼 공간을 활용한다. 예를 들어 아무리 느린 클라이언트가 요청을 보낸다고 하더라도
커넥션을 계속 붙들고 있는 게 아니라 버퍼공간에 요청, 응답 데이터를 쌓고 네트워크 준비가 완료되면 이벤트루프가 동작해서
핸들러를 통해 응답을 하는 방식이다.
즉, 현대의 대규모 웹앱을 서빙하기 위해서는 nginx 같은 비동기 이벤트 방식 아키텍처의 웹서버가 높은 효율성으로
정적, 동적 데이터를 다뤄야만 한다.
그래서 nginx는 buffering 기능이 기본적으로 on 상태이다.
Syntax: proxy_request_buffering on | off;
Default: proxy_request_buffering on;
Context: http, server, location
즉, 내 프론트엔드 서버는 스트리밍을 열심히 하고 있지만 그 응답을 브라우저로 보내주는 nginx가 버퍼공간을 활용해서 모든 응답을 모은 후에 응답준비가 완료되고 나서 response를 보내기 때문에 자바스크립트가 fallback ui를 보여줘야 하는 상황인지 모르는 것이다.
Nextjs 공식문서의 deploy의 streaming and suspense를 보면 스트리밍을 지원하기 위해서는 nginx 프록시를 사용하는 경우 버퍼링을 비활성화 해야한다는 내용이 있다.
https://nextjs.org/docs/app/building-your-application/deploying#streaming-and-suspense
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*{/}?',
headers: [
{
key: 'X-Accel-Buffering',
value: 'no',
},
],
},
]
},
}
혹은 직접 nginx 프록시 설정파일에서 버퍼링옵션을 off 할 수 있다.
server {
listen 80;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Disable buffering
proxy_buffering off;
}
}
개인적으로 nginx를 사용하는 이유를 최대한으로 누리려면 버퍼링은 필요한 기능 같은데, 이걸 꺼도 되는지는 모르겠다..
버퍼기능을 사용하지 않으면 말 그대로 유저가 요청하는 그대로 커넥션이 유지되면서 서버와 통신하게 되는 거 아닐까?
그럼 소수의 스레드를 유지하면서 비동기 방식으로 여러 요청이 들어와도 논블로킹으로 응답을 하던 웹서버의 기능을 사용하지 못한다는 거 아닌가? 만약 내 앱에 10000명이 동시접속하면 스트리밍 방식으로는 분명히 블로킹이 걸릴 텐데..
이 이슈에 대해서는 아직 의문이 있지만 nextjs공식문서에도 프록시로 제공하려면 버퍼기능을 비활성화하라고 하니 이렇게 해결했다.
nginx에 대해서는 더 공부하고 따로 블로그를 작성해 볼 계획이다.
이제 url을 입력했을 때 즉시 앱이 보이지만 무려 3초 동안이나 fallback ui가 보인다.
스트리밍은 되지만 json데이터 get요청에 문제가 있다는 말이다. 대체 어디서 3초나 걸리는지 로그를 확인해 보자
프론트엔드 서버, 백엔드 서버의 로그를 확인해보니 프론트엔드 서버의 getPostDetail이 3초를 소모하고
백엔드 서버의 postdetail은 19ms밖에 걸리지 않는다. 프론트엔드 서버의 fetch함수가 문제임을 알수 있다.
export const getPosts = async (query?: string) => {
const res = await fetch(`${host}....`);
const data: Post[] & { error: string } = await res.json();
if (data.error) {
notFound();
}
const base64_data = await Promise.all(
data.map(async (item) => {
const buffer = await fetch(item.thumbnails[0]).then(async (res) => {
return Buffer.from(await res.arrayBuffer());
});
const { base64 } = await getPlaiceholder(buffer,{size:10});
return { ...item, base64 };
})
);
return base64_data;
};
프론트엔드 서버의 getPosts 함수를 보면 백엔드 api를 호출하고 썸네일을 blur이미지로 만들기 위해 base64로 만드는 함수가 포함되어 있다. 이 부분에서 3초가 걸리는 것인데, blur이미지는 필요하기 때문에 지울 수 없다.
생각해 보면 post의 썸네일은 항상 같기 때문에 fetch함수가 호출될 때마다 base64를 다시 만들 필요가 없다.
base64 이미지를 캐싱하고 캐시에 존재하면 이 함수를 실행하지 않고 바로 리턴 시키면 된다.
이런 데이터를 캐싱하려면 클라이언트 측 메모리에 캐싱하는 방법과 레디스를 이용하는 방법이 있다.
만약 post가 계속 변경된다면 레디스를 사용하는 것이 좋지만
나의 사이드 프로젝트는 운영자만 게시물 create를 할 수 있고 몇 개 없으므로!
일단은 클라이언트 측 메모리에 캐싱을 해도 큰 문제가 없다고 판단했다.
const blurImageCache = new Map<string, string>()
export const getPosts = async (query?: string) => {
const res = await fetch(`${host}....`);
const data: Post[] & { error: string } = await res.json();
if (data.error) {
notFound();
}
const base64_data = await Promise.all(
data.map(async (item) => {
const cacheKey = item.thumbnails[0]
if(blurImageCache.has(cacheKey)){
return {...item, base64: blurImageCache.get(cacheKey)}
}
const buffer = await fetch(item.thumbnails[0]).then(async (res) => {
return Buffer.from(await res.arrayBuffer());
});
const { base64 } = await getPlaiceholder(buffer,{size:10});
blurImageCache.set(cacheKey, base64);
return { ...item, base64 };
})
);
return base64_data;
};
클라이언트 측 캐싱은 단순히 변수에 Map객체를 할당하여 작성했다. Map객체에 blur이미지가 들어갈때마다 도커이미지가 올라가 있는 ec2의 메모리를 많이 차지하게 될 것이다. 그리고 도커 이미지가 변경되어 컨테이너가 재실행될때마다 캐시가 초기화되어 첫 요청에 시간이 오래 걸릴 것 이다. 이렇게 클라이언트측 캐싱에는 단점이 많으므로 레디스를 사용하는 것 같다.
이렇게 하면 클라이언트 앱이 차지하는 메모리에 base64가 저장되어 fallback ui가 채 보이기도 전에 앱이 뜨는 것을 볼 수 있다.
나는 이미 이미지가 브라우저 캐싱되어 blur이미지도 보이지 않지만,
처음 접속하는 사람은 로딩 없이 바로 blur이미지가 삽입된 앱을 빠르게 볼 수 있고
이미지가 다운로드 완료되는 대로 실제 이미지가 반영된 홈페이지를 볼 수 있다.
'NextJS' 카테고리의 다른 글
[architecture]어떻게 시행착오를 코드에 반영할 수 있을까? (multi-zones, MFA) (7) | 2024.12.19 |
---|---|
트러블슈팅: 서버 배포 후 간헐적 ChunkLoadError 발생 원인 (1) | 2024.07.10 |
React 18.2 useOptimistic(Experimental) 낙관적 업데이트(정말 react-query, SWR은 필요없어지나!?) (0) | 2024.02.06 |
Server Action으로 form 이미지 미리보기 & Mutate 구현 (0) | 2024.01.25 |
Next14 next/font 폰트 최적화를 파보자 (4) | 2024.01.04 |