이미지를 불러올 때 다음의 과정을 통해 화면에 출력되게 된다.
이 과정이 각 이미지가 모두 실행되게 되어 이미지가 많은 경우 메인 스레드를 블로킹하게 되어 인터렉션이 막히는 문제가 발생했다.
그러니 화면에 표시될 이미지 로드를 최적화해 메인 스레드 블로킹 현상을 막고, 더욱 빠르게 유저에게 이미지를 보여줄 수 있도록 하는 작업이 필요하다.

추가로, sentry로 에러를 확인하고 있는데, 이미지 때문에 N + 1 문제가 발생하고 있는걸 확인해 개선이 필요함이 더욱 확실해졌다.


레이아웃과 커밋 단계에서 Long Task가 잡히는 것은 "브라우저가 화면을 그리느라 너무 바빠서 클릭을 무시한다"는 뜻
우리는 서버로부터 이미지 관리를 분리시켰다.
스토리지에서 이미지 데이터를 관리하고, 이를 프론트에서 직접 받아와 화면에 표시한다.
스토리지로부터 받아온 이미지를 Image 컴포넌트를 사용해 출력하고자 했는데, 자꾸 에러가 발생했다.
에러가 발생하면서 이미지 자체가 로드되지 않았다.
그래서 다음의 속성을 추가해서 스토리지로부터 받아온 이미지 경로를 그대로 사용하도록 했다.
unoptimized={true}
<Image
src={imageSrc}
unoptimized={true}
alt={alt}
className={className}
{...props}
/>
이미지 로드시 에러가 발생한 이유에 대해 정리하고 넘어가자.
먼저, Next.js가 Image 컴포넌트를 통해 이미지를 로드하고 최적화하는 방식은 다음과 같다.
하지만 외부 스토리지 이미지는 보안상의 이유로 Next.js가 함부로 접근할 수 없다.
만약 next.config.js 에 해당 도메인을 허용해주지 않으면, Nextjs 서버는 신뢰할 수 없는 출처의 이미지를 내가 대신 최적화해줄 수 없다며 에러를 내뱉는다.
하지만 우리는 이러한 문제를
unoptimized={true} 속성을 붙여서 해결했다.
이는 위의 Next.js 서버가 이미지를 최적화하는 과정을 건너뛰고, 원본 스토리지 URL로 직접 연결하기 때문에 해결된 것이다.
즉, unoptimized={true} 속성이 포함되어 있으면 이미지 로드시 성능에 영향을 준다.
그래서 먼저 이 문제를 해결하고자 했다.
이미지 최적화 작업을 진행하도록 하는 것만으로도 성능에 큰 도움이 줄 것이라고 생각했다.
next.config.ts 파일을 확인해보니, remotePattern 은 설정되어 있었다.
images: {
remotePatterns: [
...imageDomains.map((host) => ({
protocol: 'https' as const,
hostname: host,
})),
...
{
protocol: 'https',
hostname: 'kr.object.ncloudstorage.com',
pathname: '/**',
},
],
},
그럼에도 이미지 로드시 에러가 나는 이유를 찾아보니, 다음의 경우에도 문제가 발생한다고 한다.
그러 만약 외부 스토리지를 사용하는데, 이미지 로드 에러가 발생한다면 다음 두가지를 체크해보면 된다.
visibility가 public으로 되어있는가?remotePatterns 로 스토리지 경로가 추가되어 있는가?나의 경우는 ncp로 사용할 때는 remotePatterns 를 적용했음에도 이미지 로드시 에러가 발생했다.
그런데 OCI로 바꾸면서 이미지 로드 에러가 사라졌다.
아마 NCP로 동작할 때는 버킷의 visibility가 public이 아닌 값으로 설정되어 있던 것 같다는 추측을 해본다.
(OCI로 바꾸면서 이전의 NCP 서버 및 스토리지는 바로 삭제해버려 확인이 불가능하다.)
그래서 unoptimized 속성을 제거해줬는데, 그럼에도 webP가 아닌 png 와 같은 content-type 그대로 출력이 됐다.

스토리지로부터 받아오는 이미지가 아닌 경우는 webP로 잘 나오고 있는데, 스토리지로부터 받아오는 이미지만 최적화가 안되는 것 같았다.
이는 pre-signed URL의 TTL(300초)이 Next.js 이미지 최적화 캐시와 충돌하고 있어 발생한 문제였다.
문제 흐름:
/_next/image?url=<presigned-url>&w=... 요청/_next/image 응답이 없으면 → MinIO 403 반환 → 최적화 실패 → 원본 URL로 리다이렉트Pre-signed URL = "미리 서명된 URL"
일반적으로 S3/MinIO 같은 오브젝트 스토리지는 비공개(private) 이다.
아무나 접근하면 안 되니까.그런데 특정 파일을 일시적으로 공개해야 할 때, 서버가 "이 URL은 내가 허가한 거야" 라고 서명(signature)을 URL에 담아서 발급해주는 방식이 pre-signed URL이다.
이에 대한 해결 방법은 크게 두 가지이다.
/api/media-image/[id] 라우트를 만들어서 안정적인 URL을 <Image src>에 넘기는 방식. presigned URL 만료 문제 자체가 없어지고 Next.js 캐시도 제대로 작동한다.나는 이 두 방법 중에서 첫 번째 방법인 API proxy 라우트 방법을 채택했다.
가장 큰 이유는 캐시 효율이다.
pre-signed url 은 발급받을 때마다 쿼리 파라미터가 계속 바뀐다.
next.js 이미지 최적화 서버는 src 문자열 전체를 캐시 키로 사용하는데, url이 매번 바뀌면 서버는 이를 완전히 새로운 이미지로 인식하게 되어 매번 새로 다운받고 실행시키게 된다.
/api/media-image/[id] 는 변하지 않는 정적 url이다.
이 방식을 쓰면 next.js 서버가 이미지를 한 번만 최적화하고 이후에는 캐시된 데이터를 즉시 반환할 수 있어서, 서버 부하가 줄어들게 될 것이다.
그리고 pre-signed url을 클라이언트에 그대로 노출시키기보단, /api/media-image/[id] 가 캡슐화 역할을 해서 안전하게 실행시키는게 나을 것이라고 생각했다.
그리고 TTL이 짧은 URL은 브라우저가 캐싱을 꺼리게 만들기 때문에, TTL을 손대기보단 라우트를 추가해 캐싱이 잘 동작하도록 하는게 더 효율적이라고 생각했다.
즉, pre-signed url의 휘발성 문제를 해결하면서도 next.js와 브라우저가 이미지를 오래 캐싱할 수 있는 환경을 만들기 위해서 API proxy 방식을 채택했다.
그래서 라우트 파일을 아래와 같이 추가해줬다.
// src/app/api/media-image/[id]/route.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import sharp from 'sharp';
const backendUrl =
process.env.NODE_ENV === 'production'
? process.env.NEXT_PUBLIC_PRODUCTION_API_URL
: 'http://localhost:4000';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = await auth();
if (!session?.accessToken) {
return new NextResponse(null, { status: 401 });
}
// 백엔드에서 presigned URL 가져오기
const urlRes = await fetch(`${backendUrl}/v1/media/${id}/url`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!urlRes.ok) {
return new NextResponse(null, { status: urlRes.status });
}
const body = await urlRes.json();
const presignedUrl: string | undefined = body?.data?.url;
if (!presignedUrl) {
return new NextResponse(null, { status: 404 });
}
// presigned URL에서 원본 이미지 가져오기
const imageRes = await fetch(presignedUrl);
if (!imageRes.ok) {
return new NextResponse(null, { status: imageRes.status });
}
const originalBuffer = Buffer.from(await imageRes.arrayBuffer());
// WebP로 변환
const webpBuffer = await sharp(originalBuffer).webp({ quality: 85 }).toBuffer();
return new NextResponse(new Uint8Array(webpBuffer), {
headers: {
'Content-Type': 'image/webp',
// assetId는 불변(새 파일 = 새 UUID)이므로 1년 캐시
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}
흐름은 다음과 같다.
Cache-Control: immutable, 1년 캐시
sharp라이브러리?
Next.js는 기본적으로 이미지를 최적화할 때 내부적으로squoosh라는 라이브러리를 사용한다.
하지만 이는 JS 기반이라 속도가 느리고 기능이 제한적이다.
Next.js는sharp가 설치되어 있으면, 자동으로 이를 감지해 모든 이미지 최적화 작업(리사이징, webp 변환 등)을sharp에게 맡긴다.
sharp는 C++로 작성된libvips라이브러리를 사용하기 때문에, 일반적인 이미지 처리 도구보다 빠르고 메모리 소모가 적다.
- 포맷 변환(webp/avif): 사용자가 png나 jpg 원본을 요청해도, 서버에서 실시간으로
sharp를 거쳐 용량이 훨씬 작은 webp로 바꿔서 내보낸다.- 다이나믹 리사이징: 브라우저 크기에 맞춰서 이미지를 깎아준다.
그리고 기존의 AssetImage.tsx 의 imageSrc 를 위의 라우트 경로로 수정해줬다.
// url prop이 있거나 로컬/외부 URL이면 그대로, 아니면 proxy 라우트 사용
const imageSrc =
url ||
(isLocalPath || isAlreadyUrl ? assetId : `/api/media-image/${assetId}`);
...
// proxy 라우트는 이미 WebP로 변환해서 반환하므로 _next/image 최적화 불필요
const isProxyUrl = !!imageSrc?.startsWith('/api/media-image/');
return (
<Image
src={imageSrc}
alt={alt}
className={className}
unoptimized={isProxyUrl}
onError={() => setHasError(true)}
{...props}
/>
);
useMediaResolveSingle 제거해줬다.unoptimized={true} 하도록 해줬다./_next/image 재최적화 불필요하다.전체 흐름은 다음과 같다.
/api/media/[id]/url → presigned URL → MinIO (PNG/..) → /_next/image 최적화 실패/api/media-image/[id] → 서버에서 변환 → WebP 응답 (1년 캐시)결과적으론 이미 불러온 이미지의 경우 다른 페이지에서 같은 이미지를 사용할 때 불필요한 네트워크 요청이 사라졌다.
또한, 초기에 이미지를 불러올 때도 content-type 그대로 다운받아 출력하는 것에서 webp로 변환해 최적화된 이미지를 출력하는 것으로 수정되었다.

| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 이미지 포맷 | PNG/JPEG 원본 | WebP (quality 85) |
| 클라이언트 API 호출 | 이미지마다 presigned URL 발급 요청 | 없음 (proxy URL 직접 생성) |
| 캐시 | presigned URL마다 다름 (TTL 300s) | 1년 (immutable) |
| 인증 노출 | Presigned URL이 브라우저에 노출 | 브라우저는 proxy URL만 인지 |
| 로딩 상태 관리 | isLoading 처리 필요 | 불필요 (동기 URL 생성) |
위에서 next.js의 Image 컴포넌트를 활용하는 것으로 n + 1 문제는 부분적으로는 어느정도 개선했다고 볼 수 있다.
// 변경 전 n + 1 문제
[브라우저 JS 메인 스레드]
useMediaResolveMulti 실행
├─ fetch("/v1/media/id1/url") → presigned URL1 → 브라우저 이미지 다운로드
├─ fetch("/v1/media/id2/url") → presigned URL2 → 브라우저 이미지 다운로드
└─ fetch("/v1/media/id3/url") → presigned URL3 → 브라우저 이미지 다운로드
이때 js fetch 가 메인 스레드에서 n번 실행되면서 인터렉션을 차단한다.
// 변경 후
[브라우저 JS 메인 스레드]
proxyUrls = mediaIds.map(id => `/api/media-image/${id}`) ← 동기, fetch 없음
<Image src="/api/media-image/id1" /> ← 브라우저 네트워크 스택 처리 (JS 아님)
<Image src="/api/media-image/id2" />
<Image src="/api/media-image/id3" />
[서버 (Next.js)]
/api/media-image/id1 → fetch presigned URL → fetch 원본 → WebP 반환
/api/media-image/id2 → fetch presigned URL → fetch 원본 → WebP 반환
/api/media-image/id3 → fetch presigned URL → fetch 원본 → WebP 반환
수정 후에는 fetch 가 메인 스레드에서 서버로 옮겨졌기 때문에, 메인 스레드 블로킹 현상은 어느정도 개선 되었다고 볼 수 있다.
또한 캐시처리도 되었기 때문에, 초기 로드시에만 문제가 발생하고 이후론 발생하지 않을 문제이다.
추가로 Image 컴포넌트는 기본적으로 lazy loading을 하기 때문에, 스크롤로 화면에 출력해야 할 때 로드되기 때문에 한 번에 다 요청하지는 않는다.
| 변경 전 | 변경 후 | |
|---|---|---|
| JS 메인 스레드 블로킹 | N번 fetch() 실행 → 블로킹 | 없음 → 해결됨 |
| 총 네트워크 요청 수 | 동일 (N번) | 동일 (N번), 단 서버에서 실행 |
| 재방문 시 | 매번 N번 요청 | 캐시 히트 (0번 요청) |
하지만 서버 측에서의 이미지 요청 자체는 여전히 n번 발생하고 있기 때문에, 추가 개선이 필요하다.
먼저, 현재 상황의 문제점을 파악해보자.
30개가 동시 요청되면 서버에서:
auth() × 30sharp WebP 변환 × 30 (CPU 집약적)이게 여러 사용자에게 동시에 발생하면 서버 부하가 발생할 수 있다.
근본적인 n번 요청을 줄이기 위해선 mediaId 배열 → presigned Url 배열을 반환하는 batch 엔드포인트가 있어야 proxy route에서 1번 요청으로 처리할 수 있다.
그리고 우리 백엔드 팀원이 해당 엔트포인트를 이미 만들어두신 상태이다.

기존엔 단건 url 요청 엔드포인트를 사용하고 있었기 때문에, 이 여러 url 요청 엔드포인트로 수정하면 될 것이라고 판단했다.
그러니 서버 컴포넌트에서 사용해 SSR 시 presigned URL을 1번에 batch 조회하고 resolvedUrls로 내려주면, proxy route를 거치지 않고 직접 이미지를 서빙할 수 있을 것이다.
하지만 presigned url 만료(300초) 문제가 남아 있어서, hybrid 방식으로 개선하는게 안정적일 것이라고 생각했다.
getMediaUrlsServer로 batch 조회 → resolvedUrls에 담아 전달 → 빠른 초기 렌더링/api/media-image/${id})로 fallback → 1년 캐시이미지 조회 최적화가 필요한 페이지는 어떤게 있을지를 생각해봤다.
/my 하위, /group 하위, /shared, /)이 외는 그리 많은 이미지를 출력하지 않기에, 부분적으로 개선해주면 될 것이라 판단했다.
서버 컴포넌트에서 데이터를 미리 prefetch 하도록 했기 때문에, 이를 활용하는 방법으로 가져가고자 했다.
그래서 prefetch 로 데이터를 먼저 로드한 뒤, 해당 데이터를 통해 이미지 url을 한 번의 api 요청으로 가져와 캐시를 갱신하는 방법을 떠올렸다.
prefetchQuery → 캐시에 레코드 데이터 저장
↓
getQueryData → 캐시에서 데이터 읽기
↓
getMediaUrlsServer(allMediaIds) → 백엔드 1번 호출로 URL 배치 조회
↓
setQueryData → resolvedUrls 주입한 데이터로 캐시 갱신
↓
dehydrate → HydrationBoundary → 클라이언트 캐시 복원
↓
useSuspenseQuery/useQuery → 캐시 히트, resolvedUrls 있음 → proxy 호출 없이 바로 렌더링
그래서 이 로직대로 block에서 이미지 경로를 추출하는 유틸 함수와 해당 이미지 경로로 한 번에 batch api 요청을 보내는 유틸 함수를 만들어 사용해봤다.
그런데, 이미지가 출력되는 곳에서 위에서 설정해줬던 Image 최적화 로직이 실행되지 않았다.
content-type 그대로 다운받아 출력되는 문제가 발생했다.
문제를 파악한 결과로는 방금 추가한 batch 조회 때문이었다.
getMediaUrlsServer가 반환하는 건 스토리지의 presigned URL (원본 PNG/JPEG)이다.
resolvedUrls에 이걸 주입하면 브라우저가 proxy route를 우회해서 원본 파일을 직접 다운로드하게 된다.
resolvedUrls에 presigned URL 주입됨
↓
BlockContent: resolvedUrls.length > 0 → presigned URL 사용
↓
브라우저가 스토리지 직접 접근 → 원본 PNG
(proxy route 호출 안 함 → WebP 변환 없음)
그래서 기존의 로직도 실행되면서 batch 처리된 데이터도 활용할 수 있는 로직을 생각해봤는데, presigned url 특성상 캐시가 망가지게 되는 문제가 발생함을 파악했다.
방문 1: /_next/image?url=https://...?X-Amz-Date=20260302T120000Z&X-Amz-Signature=abc...
→ 캐시 저장 (키 = URL1)
방문 2: /_next/image?url=https://...?X-Amz-Date=20260302T130000Z&X-Amz-Signature=xyz...
→ 캐시 미스 (새 presigned URL → 다른 키)
→ 매번 새로 fetch + WebP 변환
presigned url은 요청할 때마다 서명이 달라지므로 /_next/image 캐시가 항상 미스된다.
그렇게 되면 매번 새로운 이미지로 인식해 캐시를 생성하지 못하고 매번 변환 연산을 수행하게 된다.
한 번의 batch 처리로 이미지를 로드할 수는 있지만, 캐시 처리가 되지 않는다면 매번 새로운 이미지를 다운받아 출력하는 과정이 필요하게 되니 오히려 성능에 안좋아진다.
결론적으론 presigned url + webp를 동시에 구현해도 캐시 효율이 현재 현재 proxy 방식보다 낮아지게 되면서 성능에도 영향을 받게 된다.
그래서 나는 batch로 pre-signed url을 미리 가져오는 방식 대신, 이미지 고유 id를 기반으로 하는 API proxy 경로(/api/media-image/[id])를 그대로 유지하는 방향을 선택했다.
/api/media-image/[id] 는 시간이 지나도 변하지 않는 정적 url이다.처음 우려했던 1번의 id 조회 → 1번의 경로 조회 문제는 id 자체를 이미지 활용함으로써 해결된다.
즉, src="/api/media-image/123" 처럼 id를 경로에 직접 포함하면, 별도의 경로 조회 api 호출 없이도 브라우저가 즉시 이미지를 요청할 수 있다.
매 요청마다 sharp 가 새롭게 webp를 생성하는 것보단, 한 번 생성된 캐시를 서빙하는게 성능 면에서 훨씬 유리하다고 판단했다.
결론적으론, 나는 네트워크 요청 횟수를 줄이기보단 요청당 효율에 집중하는 것으로 이미지 로드 최적화 작업을 마무리하기로 했다.
변경 전
[브라우저 JS 메인 스레드]
assetId 수신useMediaResolveMulti(assetId) → GET /v1/media/${assetId}/url → presigned URL (N번 fetch, 메인 스레드 블로킹)<Image src={presignedUrl} unoptimized />변경 후
[브라우저 JS 메인 스레드]
assetId 수신proxyUrls = assetIds.map(id => '/api/media-image/${id}') ← 동기, fetch 없음<Image src="/api/media-image/${id}" unoptimized />[브라우저 네트워크 스택 - JS 블로킹 없음]
/api/media-image/${id}auth() 확인/v1/media/${id}/url → presigned URL (서버-서버, 브라우저 모름)Cache-Control: immutable, max-age=31536000 헤더와 함께 반환[브라우저]
10. WebP 수신 → 1년간 HTTP 캐시 저장
11. 화면 출력 (WebP)
재방문 시:
4. GET /api/media-image/${id} → 브라우저 캐시 히트 → 요청 없음
이 상태에서 추가 성능 문제가 발생하면, 그때 다른 방법을 고려해보는게 좋을 것 같다.
아직 프론트 배포를 새로 하지 않은 상태라, 이후에 배포까지 진행하면 테스트를 다시 진행해볼 계획이다.