Skip to content

Commit ef5830c

Browse files
authored
[#654] 자체 이미지 최적화 작업 (#656)
* setting: Sharp 설치 * feat: sharp를 활용한 외부 이미지 최적화 api routes 작성 * feat: SharpImage 컴포넌트 src, width, height, alt props 구현 * feat: 로컬 이미지 최적화 기능 추가 * fix: 이미지 요청이 중복해서 2, 3번 가던 버그 수정 * feat: 이미지 리사이징 기능 추가 * feat: img태그를 unoptimized 옵션의 Next/Image로 대체 * feat: gif 지원 기능 추가 * fix: 절대경로 인식 못하던 버그 수정 * refactor: SharpImage를 Image로 변경 * feat: 각 페이지에 새로운 Image 컴포넌트 적용 * chore: 필요없는 콘솔 로그 제거 * fix: api routes를 app 라우터 컨벤션으로 수정 * fix: BookCover를 제외한 곳에서는 임시적으로 Next/Image 활용 * fix: Vercel build 에러 해결 (Sharp를 0.32.6으로 다운 그레이드) * fix: api 컨벤션 수정 * chore: common/Image.tsx 컴포넌트의 NextImage import문 수정 * fix: common/Image 타입 선언 수정
1 parent b77f55e commit ef5830c

File tree

12 files changed

+386
-20
lines changed

12 files changed

+386
-20
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"react-dom": "18.2.0",
3232
"react-error-boundary": "^3.1.4",
3333
"react-hook-form": "^7.43.2",
34-
"react-intersection-observer": "^9.4.3"
34+
"react-intersection-observer": "^9.4.3",
35+
"sharp": "^0.32.6"
3536
},
3637
"devDependencies": {
3738
"@babel/core": "^7.22.8",

src/app/api/imageOptimize/route.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import sharp from 'sharp';
3+
4+
import fs from 'fs';
5+
import path from 'path';
6+
7+
export async function GET(request: NextRequest) {
8+
const { searchParams } = new URL(request.url);
9+
10+
const src = searchParams.get('src');
11+
const width = searchParams.get('width');
12+
const height = searchParams.get('height');
13+
14+
if (!src || typeof src !== 'string') {
15+
return new NextResponse('Missing or invalid "src" query parameter', {
16+
status: 400,
17+
});
18+
}
19+
20+
const widthInt = width ? parseInt(width as string, 10) : null;
21+
const heightInt = height ? parseInt(height as string, 10) : null;
22+
const isGif = src.endsWith('.gif');
23+
24+
const getImageBuffer = async () => {
25+
if (src.startsWith('http://') || src.startsWith('https://')) {
26+
// 외부 이미지 URL 처리
27+
const response = await fetch(src, {
28+
next: { revalidate: 60 * 60 * 24 },
29+
headers: {
30+
responseType: 'arraybuffer',
31+
},
32+
});
33+
const imageBuffer = await response.arrayBuffer();
34+
35+
return imageBuffer;
36+
} else {
37+
// 로컬 이미지 경로 처리
38+
const imagePath = path.join('./public', src);
39+
const imageBuffer = fs.readFileSync(imagePath);
40+
41+
return imageBuffer;
42+
}
43+
};
44+
45+
try {
46+
const imageBuffer = await getImageBuffer();
47+
48+
// 이미지 최적화 작업
49+
const image = isGif
50+
? sharp(imageBuffer, { animated: true }).gif()
51+
: sharp(imageBuffer).webp();
52+
53+
// 이미지 리사이징
54+
if (widthInt || heightInt) {
55+
image.resize(widthInt, heightInt);
56+
}
57+
58+
const optimizedImageBuffer = await image.toBuffer();
59+
60+
// 응답 헤더 설정
61+
const contentTypeHeader = isGif
62+
? {
63+
'Content-Type': 'image/gif',
64+
}
65+
: {
66+
'Content-Type': 'image/webp',
67+
};
68+
69+
// 최적화된 이미지 전송
70+
return new NextResponse(optimizedImageBuffer, {
71+
status: 200,
72+
headers: contentTypeHeader,
73+
});
74+
} catch (error) {
75+
console.error('Error optimizing image:', error);
76+
return new NextResponse('Error optimizing image', {
77+
status: 500,
78+
});
79+
}
80+
}

src/app/global-error.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import Button from '@/components/common/Button';
4-
import Image from 'next/image';
53
import { useRouter } from 'next/navigation';
4+
import Image from 'next/image';
5+
6+
import Button from '@/components/common/Button';
67

78
export const ErrorPage = () => {
89
const router = useRouter();

src/app/group/[groupId]/not-found.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import Button from '@/components/common/Button';
2-
import Image from 'next/image';
31
import Link from 'next/link';
2+
import Image from 'next/image';
3+
4+
import Button from '@/components/common/Button';
45

56
export default function NotFound() {
67
return (

src/app/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

3-
import Image from 'next/image';
43
import Link from 'next/link';
4+
import Image from 'next/image';
55

66
import { IconKakao } from '@public/icons';
77
import { KAKAO_LOGIN_URL } from '@/constants';

src/app/not-found.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import Button from '@/components/common/Button';
4-
import Image from 'next/image';
53
import { useRouter } from 'next/navigation';
4+
import Image from 'next/image';
5+
6+
import Button from '@/components/common/Button';
67

78
const NotFound = () => {
89
const router = useRouter();

src/components/book/BookCover.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use client';
22

33
import { ComponentPropsWithoutRef, useState } from 'react';
4-
import Image from 'next/image';
4+
55
import { DATA_URL } from '@/constants';
66

7+
import Image from '@/components/common/Image';
8+
79
type BookCoverSize =
810
| 'xsmall'
911
| 'small'
@@ -78,7 +80,6 @@ const BookCover = ({ src, title, size = 'medium' }: BookCoverProps) => {
7880
>
7981
{src && !isError ? (
8082
<Image
81-
unoptimized
8283
src={src}
8384
alt={title || 'book-cover'}
8485
placeholder="blur"
@@ -89,7 +90,7 @@ const BookCover = ({ src, title, size = 'medium' }: BookCoverProps) => {
8990
/>
9091
) : (
9192
/** default cover line */
92-
<div className="absolute left-[5%] h-full w-[0.3rem] bg-black-400"></div>
93+
<div className="absolute left-[5%] h-full w-[0.3rem] bg-black-400" />
9394
)}
9495
</div>
9596
);

src/components/bookShelf/BookShelf.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const Book = ({
119119
src={imageUrl}
120120
alt={title}
121121
onLoadingComplete={handleOnLoadImage}
122-
className=" rounded-[1px] object-cover"
122+
className="rounded-[1px] object-cover"
123123
sizes="9.1rem"
124124
fill
125125
style={{ visibility: bookSpineColor ? 'visible' : 'hidden' }}

src/components/common/Avatar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

3-
import { Children, ReactNode, useState } from 'react';
43
import Image from 'next/image';
4+
import { Children, ReactNode, useState } from 'react';
55

66
type AvatarSize = 'small' | 'medium' | 'large';
77
interface AvatarProps {

src/components/common/Image.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import NextImage from 'next/image';
2+
import type { ComponentPropsWithRef } from 'react';
3+
4+
type ImageProps = Omit<ComponentPropsWithRef<typeof NextImage>, 'src'> & {
5+
src: string;
6+
};
7+
8+
const Image = ({
9+
src,
10+
alt,
11+
width,
12+
height,
13+
fill = false,
14+
sizes,
15+
className,
16+
priority = false,
17+
loading = 'lazy',
18+
placeholder = 'empty',
19+
blurDataURL,
20+
...props
21+
}: ImageProps) => {
22+
const params = new URLSearchParams({ src });
23+
24+
if (width) params.append('width', width.toString());
25+
if (height) params.append('height', height.toString());
26+
27+
const optimizedSrc = `/api/imageOptimize?${params.toString()}`;
28+
29+
return (
30+
<NextImage
31+
unoptimized
32+
src={optimizedSrc}
33+
alt={alt}
34+
width={width}
35+
height={height}
36+
fill={fill}
37+
sizes={sizes}
38+
priority={priority}
39+
loading={loading}
40+
placeholder={placeholder}
41+
blurDataURL={blurDataURL}
42+
className={className}
43+
{...props}
44+
/>
45+
);
46+
};
47+
48+
export default Image;

0 commit comments

Comments
 (0)