사이드 프로젝트를 하다가, 이미지 저장 기능을 만들어야 할 일이 생겼다. 폴라로이드 모양의 컴포넌트를 이미지로 저장해야 했다.
그리고 이미지로 저장하려면 컴포넌트를 이미지로 변환해야 했다.
그래서 컴포넌트를 이미지(png)로 변환하려고 시도해봤는데, 그 과정에서 겪은 시행착오들을 기록으로 남겨보려고 한다.
컴포넌트가 png 형식으로 성공적으로 바뀌었다👍
전체 코드는 아래와 같다. 아래 블로그를 많이 참고했다.
참고 : https://medium.com/@zero86/%EA%B0%9C%EB%B0%9C%EC%82%BD%EC%A7%88-satori-83c0d56c2a95
// app/satori/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Page() {
const [imgSrc, setImgSrc] = useState('');
useEffect(() => {
// 쿼리 파라미터 생성
const query = new URLSearchParams({
date: '2024-02-02',
text: '오늘은 불꽃축제에 다녀왔다ㅎㅎ 너무 예뻤다!! 친구와 함께 여의도에 갔다~',
uploadedImageUrl:
'이미지url',
}).toString();
// img src를 API 경로로 설정
setImgSrc(`/api/satori?${query}`);
}, []);
return (
<div>
{/* API로부터 받은 이미지(png)를 렌더링 */}
{imgSrc && <img src={imgSrc} alt="Generated PNG by satori+resvg" />}
</div>
);
}
// app/api/satori/route.tsx
import satori from 'satori';
import fs from 'fs';
import path from 'path';
import { NextResponse } from 'next/server';
// convertSvgToPngByResvg : 아래에 코드 있음
import { convertSvgToPngByResvg } from '@/app/utils/convertSvgToPngByResvg';
// 폰트 로드
const pretendardFontBuffer = fs.readFileSync(
path.join(process.cwd(), 'src', 'app', 'fonts', 'Pretendard-Regular.woff')
);
const timeFontBuffer = fs.readFileSync(
path.join(process.cwd(), 'src', 'app', 'fonts', 'time.ttf')
);
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const date = searchParams.get('date') || '';
const text = searchParams.get('text') || '';
const uploadedImageUrl = searchParams.get('uploadedImageUrl') || '';
try {
// Satori로 SVG 생성
const svg = await satori(
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
height: '100vh',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
}}
>
<div style={{ display: 'flex', position: 'relative' }}>
<img
src="/frame.jpg"
alt="폴라로이드"
style={{ height: '516px', width: '324px' }}
/>
</div>
<img
src={uploadedImageUrl}
alt="이미지"
style={{
display: 'flex',
objectFit: 'contain',
position: 'absolute',
top: '43px',
left: '54px',
height: '382px',
width: '288px',
}}
/>
<div
style={{
display: 'flex',
position: 'absolute',
bottom: '154px',
right: '90px',
fontFamily: 'timeFont',
color: '#facc15',
}}
>
<span>{date}</span>
</div>
<div
style={{
display: 'flex',
width: '100%',
position: 'relative',
bottom: '70px',
right: '-50px',
}}
>
<div
style={{
display: 'flex',
width: '100%',
padding: '8px',
zIndex: 50,
backgroundColor: 'transparent',
resize: 'none',
fontSize: '15px',
color: '#1F2937',
maxWidth: '300px',
}}
>
{text}
</div>
</div>
</div>
</div>,
{
width: 400,
height: 500,
fonts: [
{
style: 'normal',
name: 'pretendard',
data: pretendardFontBuffer,
weight: 600,
},
{
style: 'normal',
name: 'timeFont',
data: timeFontBuffer,
weight: 600,
},
],
}
);
const pngBuffer = convertSvgToPngByResvg(svg);
// 성공적으로 SVG를 생성한 후 반환
return new NextResponse(pngBuffer, {
status: 200,
headers: {
'Content-Type': 'image/png',
},
});
} catch (error) {
console.error('SVG 생성 오류:', error);
return new NextResponse('SVG 생성 오류', { status: 500 });
}
}
// app/utils/convertSvgToPngByResvg.ts
import { Resvg } from '@resvg/resvg-js';
export function convertSvgToPngByResvg(targetSvg: Buffer | string) {
const resvg = new Resvg(targetSvg, {});
const pngData = resvg.render();
return pngData.asPng();
}
전체 코드는 아래와 같다.
// app/og/page.tsx
export default function Page() {
const date = '2024-02-02';
const text =
'오늘은 불꽃축제에 다녀왔다ㅎㅎ 너무 예뻤다!! 친구와 함께 여의도에 갔다~';
const uploadedImageUrl = '이미지 url';
return (
<div>
<img
src={`http://localhost:3000/api/og?date=${date}&text=${text}&uploadedImageUrl=${uploadedImageUrl}`}
alt="Generated PNG by @vercel/og"
/>
</div>
);
}
공식문서를 참고하여 ImageResponse로 이미지를 생성했다.
참고 : https://vercel.com/docs/functions/og-image-generation
// // app/api/og/route.tsx
import { ImageResponse } from 'next/og';
// 공식문서 주석
// App router includes @vercel/og.
// No need to install it.
import fs from 'fs';
import path from 'path';
// 폰트 로드
const pretendardFontBuffer = fs.readFileSync(
path.join(process.cwd(), 'src', 'app', 'fonts', 'Pretendard-Regular.woff')
);
const timeFontBuffer = fs.readFileSync(
path.join(process.cwd(), 'src', 'app', 'fonts', 'time.ttf')
);
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const date = searchParams.get('date') || '';
const text = searchParams.get('text') || '';
const uploadedImageUrl = searchParams.get('uploadedImageUrl') || '';
return new ImageResponse(
(
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
height: '100vh',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
}}
>
<div style={{ display: 'flex', position: 'relative' }}>
<img
src="이미지 url"
alt="폴라로이드"
style={{ height: '516px', width: '324px' }}
/>
</div>
<img
src={uploadedImageUrl}
alt="이미지"
style={{
display: 'flex',
objectFit: 'contain',
position: 'absolute',
top: '43px',
left: '54px',
height: '382px',
width: '288px',
}}
/>
<div
style={{
display: 'flex',
position: 'absolute',
bottom: '154px',
right: '90px',
fontFamily: 'timeFont',
color: '#facc15',
}}
>
<span>{date}</span>
</div>
<div
style={{
display: 'flex',
width: '100%',
position: 'relative',
bottom: '70px',
right: '-50px',
}}
>
<div
style={{
display: 'flex',
width: '100%',
padding: '8px',
zIndex: 50,
backgroundColor: 'transparent',
resize: 'none',
fontSize: '15px',
color: '#1F2937',
maxWidth: '300px',
}}
>
{text}
</div>
</div>
</div>
</div>
),
{
width: 400,
height: 500,
fonts: [
{
style: 'normal',
name: 'pretendard',
data: pretendardFontBuffer,
weight: 600,
},
{
style: 'normal',
name: 'timeFont',
data: timeFontBuffer,
weight: 600,
},
],
}
);
}
사실 Satori와 @vercel/og를 쓰기 전에 html-to-png라는 라이브러리를 썼었다. 컴포넌트 자체를 코드 변형 없이 이미지로 다운로드 할 수 있게 해준다고 한다.
그런데 큰 오류가 있었다. 예를 들어 다운로드가 되긴 하는데 3의 배수번째에서만 정상적으로 이미지가 뽑혔다. 이 오류를 해결하지 않는 한 쓸 수 없다고 판단했다.
아래는 3의 배수번째에만 정상적으로 이미지가 뽑혔던 경우의 예시이다. 오류를 해결하지 못해서 결국 Satori와 같은 다른 방법을 찾게 되었다.
satori에서 woff2 폰트를 쓰면 오류가 난다. 그래서 woff나 ttf인 폰트를 써봤더니 잘 됐다.
찾아보니 woff2를 지원하지 않는다고 한다.
Currently opentype.js doesn't support woff2, which is usually 30% the size of woff. Fontkit might be a good alternative for this (but the lib is larger). / Decided to put a lower priority on this. TTF & OTF are faster to load and parse, so on the server side we will always recommend using them unless there's a size limitation. WOFF is a good balance of size and parsing speed (smaller but slightly longer to load).
참고 : https://github.com/vercel/satori/issues/3
Module parse failed: Unexpected character '�'
resvg 사용시에 자꾸 Module parse failed: Unexpected character '�'
라는 오류가 나는데 검색해봐도 해결방법을 찾을 수 없었다.
그래서 개발방에 질문을 올렸고, 해결 방법을 얻을 수 있었다. next config 파일에 아래 코드를 추가하면 된다.
// next.config.js
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ["@resvg/resvg-js"],
},
};
module.exports = nextConfig;
해결 방법을 알려주신 분이 남겨주신 코멘트는 아래와 같다. 디버깅 과정을 자세하게 설명해주셨다.
디버깅 과정
Module parse failed: Unexpected character '�' 라는 에러가 발생했다
저 물음표 아이콘은 유효한 유니코드 문자가 아닐 때 나오는 문자임, 즉 뭔가 텍스트가 아닌 파일을 쓰려고 하고 있음
근데 그 파일이 뭘까? 에러 스크린샷을 보면 resvgjs.win32-x64-msvc.node 파일을 불러오다가 난 에러인 걸 알 수 있음
.node 파일은 Node.js에서 Native Addons를 만드는 데 쓰는 파일임
그런데 에러 내용은 appropriate loader를 못 찾았다는 내용, 즉 Webpack에서 발생한 에러임
Node.js에서 처리해야 하는 파일을 Webpack이 읽고 있음
배경 지식
Node.js Native Addons: JavaScript가 아니라 C/C++/Rust 등의 언어로 작성되어 바이너리로 컴파일된 모듈이에요. 주로 성능 향상을 위해 쓰이고, 바이너리라는 특성 상 각 플랫폼마다 다른 파일을 준비해야 해요 (그래서 위 에러에서도 win32라는 게 보임)
해결 방법
Webpack이 아니라 Node.js가 해당 파일을 처리하게 해야 함 -> Webpack이 해당 파일을 처리하지 못하는 방법을 찾아내자
Webpack 등 번들러가 모듈을 처리하지 않고 그대로 두길 원하는 경우, externals에 해당 모듈을 추가할 수 있습니다
근데 Next.js는 직접 Webpack 설정을 관리하니 직접 건드리는 건 안 좋음 -> 열어둔 다른 방법이 있을 것이다
nextjs native modules in app router 검색하고 생각한거랑 비슷한 해결책 찾아보다가 저 이슈 찾음
갑자기 화면에 이미지가 아니라 깨진 문자들이 보이기 시작했다. 이건 Content-Type을 image/png가 아니라 svg로 설정했기 때문이었다. Content-Type을 image/png로 설정하고 해결되었다.
return new NextResponse(pngBuffer, {
status: 200,
headers: {
'Content-Type': 'image/png',
},
});
Can't deploy to Vercel - error - Route does not match the required types of a Next.js Route
찾아보니 한 파일에 export가 두개일 때 나타날 수 있는 오류라고 한다. 그래서 따로 파일을 분리했더니 해결되었다.
I also had more than one export on a page.tsx file which seems like that was the issue. Moving the 2nd export to it's own file solved the issue.
참고 : https://www.reddit.com/r/nextjs/comments/1achrkw/cant_deploy_to_vercel_error_route_does_not_match/
Error: Cannot access Image.toString on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.
이 오류가 해결이 안됐었다. 그래서 Satori + resvg를 포기하고 @vercel/og로 넘어갔다. 그런데도, 같은 오류가 났다. 그래서 열심히 디버깅을 한 결과... 서버 컴포넌트(src/app/api/satori/route.tsx, src/app/api/og/route.tsx)에서 Image(next/image)를 써서 그랬다. Image를 img태그로 바꾸니 해결되었다.
찾아보니 next/image는 클라이언트사이드에서만 사용할 수 있다고 한다.
참고 : https://github.com/vercel/next.js/issues/41924
컴포넌트가 png 형식으로 바뀌었다👍
이미지 변환을 html-to-png 라이브러리로 쉽게 할 수 있을 줄 알았다. 그런데 예상외의 버그로 되지 않았고, 그래서 Satori + resvg-js를 쓰게 되고.. 그런데 오류가 나서 @vercel/og를 쓰게되고..
NextJS App router를 이번 프로젝트에서 처음 써보며 @vercel/og도 처음 알게 되었는데, NextJS App router에 내장되어있는 기능이라니 신기했다.
그리고 Satori도 말로만 들었었고 해보기 전에는 막막했지만 다행히 한국어로 된 블로그가 하나 있어서 이정표로 잡고 할 수 있었다. 관련 자료를 잘 찾지 못했는데, 검색을 잘못했나 싶다. 다음부터는 한국어 자료가 없다면 영문 자료도 필수적으로 찾아봐야겠다.
이번에는 오류가 참 많았다. 해결될라치면 또 오류가 나고 해결하면 또 오류가 나고.. 그래도 포기하지 않고 질문방에 질문도 올리고, 열심히 디버깅한 점을 칭찬해주고 싶다. 배경지식이 하나도 없어서 많이 헤맸는데 시행착오를 거치면서 조금씩 알아갈 수 있어 뿌듯했다.
질문을 올렸을 때 한 분이 해결 방법과 함께 디버깅 과정을 상세히 공유해주셔서 너무 감사했다. 그분의 디버깅 과정을 읽어보니 배경지식이 중요한 것 같다고 느꼈다.
이미지로 변환했으니, 다음번에는 이미지 다운로드 기능을 만들어야 한다. 다음 과정도 화이팅!!