feat: 지붕재적합성 pdf 다운로드 중 다량데이터 요청 시 서버 shutdown 케이스 방어처리 추가 #58

Merged
swyoo merged 1 commits from feature/suitable into dev 2025-06-04 18:12:40 +09:00
4 changed files with 109 additions and 30 deletions

37
package-lock.json generated
View File

@ -22,6 +22,7 @@
"mysql2": "^3.14.1",
"next": "15.2.4",
"nodemailer": "^7.0.3",
"pdf-lib": "^1.17.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-to-pdf": "^2.0.0",
@ -1555,6 +1556,24 @@
"node": ">=0.10"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@prisma/client": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.7.0.tgz",
@ -4120,6 +4139,24 @@
"node": ">=8"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",

View File

@ -29,6 +29,7 @@
"mysql2": "^3.14.1",
"next": "15.2.4",
"nodemailer": "^7.0.3",
"pdf-lib": "^1.17.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-to-pdf": "^2.0.0",

View File

@ -1,6 +1,7 @@
import React from 'react'
import { NextRequest, NextResponse } from 'next/server'
import { pdf, Document } from '@react-pdf/renderer'
import { PDFDocument } from 'pdf-lib'
import { prisma } from '@/libs/prisma'
import { type Suitable } from '@/types/Suitable'
import SuitablePdf from '@/components/pdf/SuitablePdf'
@ -65,14 +66,32 @@ export async function POST(request: NextRequest) {
// 데이터 조회
const suitable: Suitable[] = await prisma.$queryRawUnsafe(query)
// pdf 생성
const content = React.createElement(Document, null, React.createElement(SuitablePdf, { data: suitable, fileTitle: fileTitle }))
const pdfBlob = await pdf(content).toBlob()
// pdf 생성 : mainId 100개씩 청크로 나누기
const CHUNK_SIZE = 100
const pdfBuffers: Uint8Array[] = []
for (let i = 0; i < suitable.length; i += CHUNK_SIZE) {
const chunk = suitable.slice(i, i + CHUNK_SIZE)
const content = React.createElement(
Document,
null,
React.createElement(SuitablePdf, {
data: chunk,
fileTitle: fileTitle,
firstPage: i === 0,
}),
)
const pdfBlob = await pdf(content).toBlob()
const arrayBuffer = await pdfBlob.arrayBuffer()
pdfBuffers.push(new Uint8Array(arrayBuffer))
}
// 모든 PDF 버퍼 병합
const mergedPdfBytes = await mergePdfBuffers(pdfBuffers)
const fileName = `${fileTitle.replace(' ', '_')}.pdf`
const encodedFileName = encodeURIComponent(fileName)
return new Response(pdfBlob, {
return new NextResponse(Buffer.from(mergedPdfBytes), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="suitable.pdf"; filename*=UTF-8''${encodedFileName}`,
@ -83,3 +102,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '데이터 조회 중 오류가 발생했습니다' }, { status: 500 })
}
}
async function mergePdfBuffers(buffers: Uint8Array[]) {
const mergedPdf = await PDFDocument.create()
for (const buf of buffers) {
const pdf = await PDFDocument.load(buf)
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices())
copiedPages.forEach((page) => mergedPdf.addPage(page))
}
const mergedPdfBytes = await mergedPdf.save()
return mergedPdfBytes
}

View File

@ -63,35 +63,42 @@ const formatDateString = () => {
return `${year}${month}${day}${hours}:${minutes.toString().padStart(2, '0')}`
}
export default function SuitablePdf({ data, fileTitle }: { data: Suitable[]; fileTitle: string }) {
export default function SuitablePdf({ data, fileTitle, firstPage }: { data: Suitable[]; fileTitle: string; firstPage: boolean }) {
const createTime = formatDateString()
return (
<Page size="A4" orientation="landscape" style={styles.page}>
{/* Intro Section */}
<View>
<Text style={[styles.text]}></Text>
<Text style={[styles.text]}>{fileTitle}</Text>
<Text style={[styles.text]}>{createTime}</Text>
</View>
<View>
<Text style={[styles.text]}>使</Text>
<Text style={[styles.text]}></Text>
<Text style={[styles.text]}>
</Text>
<Text style={[styles.text]}></Text>
</View>
{firstPage && (
<View>
<View>
<Text style={styles.text}></Text>
<Text style={styles.text}>{fileTitle}</Text>
<Text style={styles.text}>{createTime}</Text>
</View>
<View>
<Text style={styles.text}>使</Text>
<Text style={styles.text}></Text>
<Text style={styles.text}>
</Text>
<Text style={styles.text}></Text>
</View>
</View>
)}
<View>
{/* Cards Section */}
{data?.map((item: Suitable) => (
<View key={item.id}>
<Text style={styles.text}>{item.productName}</Text>
<Text style={styles.text}>{item.manuFtCd}</Text>
<Text style={styles.text}>{item.roofMtCd}</Text>
{/* Table Title */}
<View>
<Text style={styles.text}>{item.productName}</Text>
<Text style={styles.text}>{item.manuFtCd}</Text>
<Text style={styles.text}>{item.roofMtCd}</Text>
</View>
{/* Table */}
<View style={styles.table}>
{/* Table Header */}
<View style={styles.tableRow}>
@ -102,14 +109,16 @@ export default function SuitablePdf({ data, fileTitle }: { data: Suitable[]; fil
</View>
{/* Table Body */}
{JSON.parse(item.detail)?.map((subItem: SuitableDetail) => (
<View key={subItem.id} style={styles.tableRow}>
<Text style={[styles.tableCol, styles.text]}>{item.roofShCd}</Text>
<Text style={[styles.tableCol, styles.text]}>{subItem.trestleMfpcCd}</Text>
<Text style={[styles.tableCol, styles.text]}>{subItem.trestleManufacturerProductName}</Text>
<Text style={[styles.tableCol, styles.text]}>{subItem.memo}</Text>
</View>
))}
<View>
{JSON.parse(item.detail)?.map((subItem: SuitableDetail) => (
<View key={subItem.id} style={styles.tableRow}>
<Text style={[styles.tableCol, styles.text]}>{item.roofShCd}</Text>
<Text style={[styles.tableCol, styles.text]}>{subItem.trestleMfpcCd}</Text>
<Text style={[styles.tableCol, styles.text]}>{subItem.trestleManufacturerProductName}</Text>
<Text style={[styles.tableCol, styles.text]}>{subItem.memo}</Text>
</View>
))}
</View>
</View>
</View>
))}