From 930d15093454896596611cab0fead246f1995e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Mar 2026 04:31:56 +0900 Subject: [PATCH 01/40] =?UTF-8?q?205=20feat=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=A0=84=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 전처리 로직 및 WebWorker 구현 * feat: 전처리 적용 및 preview 동시성 제어 로직 추가 * refactor: 리뷰 반영 --- src/pages/Club/Application/clubFeePage.tsx | 25 +- .../Manager/ManagedClubProfile/index.tsx | 55 +++- .../Manager/ManagedRecruitmentWrite/index.tsx | 105 ++++++-- src/utils/ts/imagePreprocessor.ts | 243 ++++++++++++++++++ src/utils/ts/imagePreprocessor.worker.ts | 132 ++++++++++ src/utils/ts/promise.ts | 32 +++ 6 files changed, 550 insertions(+), 42 deletions(-) create mode 100644 src/utils/ts/imagePreprocessor.ts create mode 100644 src/utils/ts/imagePreprocessor.worker.ts create mode 100644 src/utils/ts/promise.ts diff --git a/src/pages/Club/Application/clubFeePage.tsx b/src/pages/Club/Application/clubFeePage.tsx index f1c59be8..31a3dd7e 100644 --- a/src/pages/Club/Application/clubFeePage.tsx +++ b/src/pages/Club/Application/clubFeePage.tsx @@ -7,6 +7,7 @@ import Portal from '@/components/common/Portal'; import { useClubApplicationStore } from '@/stores/clubApplicationStore'; import useBooleanState from '@/utils/hooks/useBooleanState'; import useUploadImage from '@/utils/hooks/useUploadImage'; +import { prepareImageFile } from '@/utils/ts/imagePreprocessor'; import AccountInfoCard from './components/AccountInfo'; import useApplyToClub from './hooks/useApplyToClub'; import { useGetClubFee } from './hooks/useGetClubFee'; @@ -28,8 +29,9 @@ function ClubFeePage() { const fileInputRef = useRef(null); const [previewUrl, setPreviewUrl] = useState(null); const [imageFile, setImageFile] = useState(null); + const [isPreparingImage, setIsPreparingImage] = useState(false); const { value: isImageOpen, setTrue: openImage, setFalse: closeImage } = useBooleanState(); - const isSubmitting = isApplyingToClub || isUploadingImage; + const isSubmitting = isApplyingToClub || isPreparingImage || isUploadingImage; useEffect(() => { return () => { @@ -38,17 +40,26 @@ function ClubFeePage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleImageSelect = (e: React.ChangeEvent) => { + const handleImageSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file || isPreparingImage) return; - if (previewUrl) URL.revokeObjectURL(previewUrl); - setImageFile(file); - setPreviewUrl(URL.createObjectURL(file)); + setIsPreparingImage(true); + + try { + const preparedFile = await prepareImageFile(file); + + if (previewUrl) URL.revokeObjectURL(previewUrl); + setImageFile(preparedFile); + setPreviewUrl(URL.createObjectURL(preparedFile)); + } finally { + setIsPreparingImage(false); + } }; const handleSubmit = async () => { if (!imageFile) return; + const { fileUrl } = await uploadImage(imageFile); await applyToClub({ answers, feePaymentImageUrl: fileUrl }); }; @@ -123,7 +134,7 @@ function ClubFeePage() { onClick={handleSubmit} disabled={!imageFile || isSubmitting} > - {isSubmitting ? '제출 중...' : '제출하기'} + {isPreparingImage ? '이미지 준비 중...' : isSubmitting ? '제출 중...' : '제출하기'} {isImageOpen && previewUrl && ( diff --git a/src/pages/Manager/ManagedClubProfile/index.tsx b/src/pages/Manager/ManagedClubProfile/index.tsx index d1e14669..1d8964eb 100644 --- a/src/pages/Manager/ManagedClubProfile/index.tsx +++ b/src/pages/Manager/ManagedClubProfile/index.tsx @@ -7,6 +7,7 @@ import { useGetClubDetail } from '@/pages/Club/ClubDetail/hooks/useGetClubDetail import { useUpdateClubInfo } from '@/pages/Manager/hooks/useManagedClubs'; import useBooleanState from '@/utils/hooks/useBooleanState'; import useUploadImage from '@/utils/hooks/useUploadImage'; +import { prepareImageFile } from '@/utils/ts/imagePreprocessor'; const DESCRIPTION_MAX_LENGTH = 25; @@ -48,9 +49,11 @@ function ManagedClubInfo() { const [introduce, setIntroduce] = useState(initialIntroduce); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(initialImageUrl); + const [isPreparingImage, setIsPreparingImage] = useState(false); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); + const imageDeletedRef = useRef(false); const localPreviewUrlRef = useRef(null); const { mutateAsync: uploadImage, error: uploadError } = useUploadImage('CLUB'); @@ -86,26 +89,42 @@ function ManagedClubInfo() { } }; - const handleImageSelect = (e: ChangeEvent) => { + const handleImageSelect = async (e: ChangeEvent) => { const file = e.target.files?.[0]; - if (!file) return; + if (!file || isPreparingImage) return; - clearLocalPreviewUrl(localPreviewUrlRef); + imageDeletedRef.current = false; + setIsPreparingImage(true); - const previewUrl = URL.createObjectURL(file); - localPreviewUrlRef.current = previewUrl; + try { + const preparedFile = await prepareImageFile(file); - setImageFile(file); - setImagePreview(previewUrl); - e.target.value = ''; + if (imageDeletedRef.current) { + e.target.value = ''; + return; + } + + clearLocalPreviewUrl(localPreviewUrlRef); + + const previewUrl = URL.createObjectURL(preparedFile); + localPreviewUrlRef.current = previewUrl; + + setImageFile(preparedFile); + setImagePreview(previewUrl); + e.target.value = ''; + } finally { + setIsPreparingImage(false); + } }; const handleImageClick = () => { + if (isPreparingImage) return; fileInputRef.current?.click(); }; const handleDeleteImage = () => { + imageDeletedRef.current = true; clearLocalPreviewUrl(localPreviewUrlRef); setImageFile(null); setImagePreview(''); @@ -262,10 +281,16 @@ function ManagedClubInfo() { @@ -276,11 +301,17 @@ function ManagedClubInfo() {
) : (
{`업로드 × @@ -447,16 +498,18 @@ function ManagedRecruitmentWrite() { @@ -509,9 +562,15 @@ function ManagedRecruitmentWrite() {
diff --git a/src/utils/ts/imagePreprocessor.ts b/src/utils/ts/imagePreprocessor.ts new file mode 100644 index 00000000..9ac2ce0d --- /dev/null +++ b/src/utils/ts/imagePreprocessor.ts @@ -0,0 +1,243 @@ +const DEFAULT_MAX_DIMENSION = 1600; +const DEFAULT_QUALITY = 0.9; +const JPEG_MIME_TYPE = 'image/jpeg'; +const PNG_MIME_TYPE = 'image/png'; +const WEBP_MIME_TYPE = 'image/webp'; +const SUPPORTED_IMAGE_MIME_TYPES = new Set([JPEG_MIME_TYPE, PNG_MIME_TYPE, WEBP_MIME_TYPE]); + +interface ImagePreprocessRequest { + id: number; + file: File; + maxDimension?: number; + quality?: number; +} + +interface ImagePreprocessResponse { + file: File; + id: number; +} + +interface ImagePreprocessErrorResponse { + errorMessage: string; + id: number; +} + +interface PrepareImageFileOptions { + maxDimension?: number; + quality?: number; +} + +interface PendingRequestHandlers { + reject: (error: unknown) => void; + resolve: (value: ImagePreprocessResponse | null) => void; +} + +let imagePreprocessWorker: Worker | null = null; +let messageId = 0; +const pendingImagePreprocessRequests = new Map(); + +function getOutputMimeType() { + return WEBP_MIME_TYPE; +} + +function getOutputFileName(fileName: string, mimeType: string) { + const nextExtension = mimeType === WEBP_MIME_TYPE ? 'webp' : 'jpg'; + const sanitizedFileName = fileName.replace(/\.[^.]+$/, ''); + + return `${sanitizedFileName}.${nextExtension}`; +} + +function getTargetDimensions(width: number, height: number, maxDimension: number) { + const largestDimension = Math.max(width, height); + + if (largestDimension <= maxDimension) { + return { height, width }; + } + + const scale = maxDimension / largestDimension; + + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +function resetImagePreprocessWorker() { + imagePreprocessWorker?.terminate(); + imagePreprocessWorker = null; +} + +function getWorker() { + if (typeof Worker === 'undefined') { + return null; + } + + if (!imagePreprocessWorker) { + try { + const worker = new Worker(new URL('./imagePreprocessor.worker.ts', import.meta.url), { + type: 'module', + }); + + worker.onmessage = (event: MessageEvent) => { + const currentRequest = pendingImagePreprocessRequests.get(event.data.id); + + if (!currentRequest) { + return; + } + + pendingImagePreprocessRequests.delete(event.data.id); + + if ('errorMessage' in event.data) { + currentRequest.reject(new Error(event.data.errorMessage)); + return; + } + + currentRequest.resolve(event.data); + }; + worker.onerror = (event) => { + pendingImagePreprocessRequests.forEach(({ reject }) => { + reject(new Error(event.message || '이미지 전처리 worker에서 오류가 발생했습니다.')); + }); + pendingImagePreprocessRequests.clear(); + resetImagePreprocessWorker(); + }; + + imagePreprocessWorker = worker; + } catch { + resetImagePreprocessWorker(); + return null; + } + } + + return imagePreprocessWorker; +} + +async function runWorkerPreprocess(file: File, options: PrepareImageFileOptions) { + let worker: Worker | null; + + try { + worker = getWorker(); + } catch { + resetImagePreprocessWorker(); + return null; + } + + if (!worker || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') { + return null; + } + + const id = messageId++; + const requestPayload: ImagePreprocessRequest = { + file, + id, + maxDimension: options.maxDimension ?? DEFAULT_MAX_DIMENSION, + quality: options.quality ?? DEFAULT_QUALITY, + }; + + return new Promise((resolve, reject) => { + try { + pendingImagePreprocessRequests.set(id, { reject, resolve }); + worker.postMessage(requestPayload); + } catch { + pendingImagePreprocessRequests.delete(id); + resetImagePreprocessWorker(); + resolve(null); + } + }); +} + +async function loadImageElement(file: File) { + const previewUrl = URL.createObjectURL(file); + + try { + return await new Promise((resolve, reject) => { + const imageElement = new Image(); + imageElement.onload = () => resolve(imageElement); + imageElement.onerror = () => reject(new Error('이미지 로딩에 실패했습니다.')); + imageElement.src = previewUrl; + }); + } finally { + URL.revokeObjectURL(previewUrl); + } +} + +async function convertCanvasToBlob( + canvasElement: HTMLCanvasElement, + outputMimeType: string, + quality: number | undefined +) { + return new Promise((resolve, reject) => { + canvasElement.toBlob( + (blob) => { + if (!blob) { + reject(new Error('이미지 Blob 생성에 실패했습니다.')); + return; + } + + resolve(blob); + }, + outputMimeType, + quality + ); + }); +} + +async function runMainThreadPreprocess(file: File, options: PrepareImageFileOptions) { + const imageElement = await loadImageElement(file); + const originalWidth = imageElement.naturalWidth || imageElement.width; + const originalHeight = imageElement.naturalHeight || imageElement.height; + const outputMimeType = getOutputMimeType(); + const { width: outputWidth, height: outputHeight } = getTargetDimensions( + originalWidth, + originalHeight, + options.maxDimension ?? DEFAULT_MAX_DIMENSION + ); + + if (outputWidth === originalWidth && outputHeight === originalHeight && file.type === outputMimeType) { + return file; + } + + const canvasElement = document.createElement('canvas'); + canvasElement.width = outputWidth; + canvasElement.height = outputHeight; + const context = canvasElement.getContext('2d', { + alpha: true, + }); + + if (!context) { + throw new Error('2D canvas context를 생성하지 못했습니다.'); + } + + context.drawImage(imageElement, 0, 0, outputWidth, outputHeight); + + const blob = await convertCanvasToBlob(canvasElement, outputMimeType, options.quality ?? DEFAULT_QUALITY); + + return new File([blob], getOutputFileName(file.name, blob.type || outputMimeType), { + lastModified: Date.now(), + type: blob.type || outputMimeType, + }); +} + +export async function prepareImageFile(file: File, options: PrepareImageFileOptions = {}) { + if (!SUPPORTED_IMAGE_MIME_TYPES.has(file.type)) { + return file; + } + + try { + let workerResponse: ImagePreprocessResponse | null = null; + + try { + workerResponse = await runWorkerPreprocess(file, options); + } catch { + resetImagePreprocessWorker(); + } + + if (workerResponse) { + return workerResponse.file; + } + + return await runMainThreadPreprocess(file, options); + } catch { + return file; + } +} diff --git a/src/utils/ts/imagePreprocessor.worker.ts b/src/utils/ts/imagePreprocessor.worker.ts new file mode 100644 index 00000000..0f0b8195 --- /dev/null +++ b/src/utils/ts/imagePreprocessor.worker.ts @@ -0,0 +1,132 @@ +const DEFAULT_MAX_DIMENSION = 1600; +const DEFAULT_QUALITY = 0.9; +const WEBP_MIME_TYPE = 'image/webp'; + +interface ImagePreprocessRequest { + id: number; + file: File; + maxDimension?: number; + quality?: number; +} + +interface ImagePreprocessResponse { + durationMs: number; + file: File; + id: number; + originalHeight: number; + originalWidth: number; + outputHeight: number; + outputMimeType: string; + outputWidth: number; + skipped: boolean; + workerUsed: boolean; +} + +function getOutputMimeType() { + return WEBP_MIME_TYPE; +} + +function getOutputFileName(fileName: string, mimeType: string) { + const nextExtension = mimeType === WEBP_MIME_TYPE ? 'webp' : 'jpg'; + const sanitizedFileName = fileName.replace(/\.[^.]+$/, ''); + + return `${sanitizedFileName}.${nextExtension}`; +} + +function getTargetDimensions(width: number, height: number, maxDimension: number) { + const largestDimension = Math.max(width, height); + + if (largestDimension <= maxDimension) { + return { height, width }; + } + + const scale = maxDimension / largestDimension; + + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + }; +} + +self.onmessage = async (event: MessageEvent) => { + const startedAt = performance.now(); + const { file, id } = event.data; + const maxDimension = event.data.maxDimension ?? DEFAULT_MAX_DIMENSION; + const quality = event.data.quality ?? DEFAULT_QUALITY; + + try { + const bitmap = await createImageBitmap(file); + const originalWidth = bitmap.width; + const originalHeight = bitmap.height; + const { width: outputWidth, height: outputHeight } = getTargetDimensions( + originalWidth, + originalHeight, + maxDimension + ); + const outputMimeType = getOutputMimeType(); + + if (outputWidth === originalWidth && outputHeight === originalHeight && file.type === outputMimeType) { + bitmap.close(); + const response: ImagePreprocessResponse = { + durationMs: Math.round(performance.now() - startedAt), + file, + id, + originalHeight, + originalWidth, + outputHeight, + outputMimeType: file.type || outputMimeType, + outputWidth, + skipped: true, + workerUsed: true, + }; + self.postMessage(response); + return; + } + + const canvas = new OffscreenCanvas(outputWidth, outputHeight); + const context = canvas.getContext('2d', { + alpha: true, + desynchronized: true, + }); + + if (!context) { + bitmap.close(); + throw new Error('2D canvas context를 생성하지 못했습니다.'); + } + + context.drawImage(bitmap, 0, 0, outputWidth, outputHeight); + bitmap.close(); + + const blob = await canvas.convertToBlob({ + quality, + type: outputMimeType, + }); + + const nextFile = new File([blob], getOutputFileName(file.name, blob.type || outputMimeType), { + lastModified: Date.now(), + type: blob.type || outputMimeType, + }); + + const response: ImagePreprocessResponse = { + durationMs: Math.round(performance.now() - startedAt), + file: nextFile, + id, + originalHeight, + originalWidth, + outputHeight, + outputMimeType: nextFile.type || outputMimeType, + outputWidth, + skipped: false, + workerUsed: true, + }; + + self.postMessage(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '이미지 전처리에 실패했습니다.'; + + self.postMessage({ + errorMessage, + id, + }); + } +}; diff --git a/src/utils/ts/promise.ts b/src/utils/ts/promise.ts new file mode 100644 index 00000000..89bb1fc5 --- /dev/null +++ b/src/utils/ts/promise.ts @@ -0,0 +1,32 @@ +export async function mapWithConcurrencyLimit( + items: T[], + concurrency: number, + iteratee: (item: T, index: number) => Promise, + onResolved?: (result: TResult, index: number) => void +) { + if (items.length === 0) { + return []; + } + + const results = new Array(items.length); + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + + const workers = Array.from({ length: workerCount }, async () => { + while (true) { + const currentIndex = nextIndex++; + + if (currentIndex >= items.length) { + return; + } + + const result = await iteratee(items[currentIndex], currentIndex); + results[currentIndex] = result; + onResolved?.(result, currentIndex); + } + }); + + await Promise.all(workers); + + return results; +} From b7f6e6885091d587aad8da81d5d212c6bf21d3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Mon, 23 Mar 2026 12:16:43 +0900 Subject: [PATCH 02/40] =?UTF-8?q?[hotfix]=20=ED=95=98=EB=8B=A8=EB=B0=94=20?= =?UTF-8?q?=EB=84=88=EB=B9=84=20=EC=88=98=EC=A0=95=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * hotfix: 하단바 너비 수정 * chore: 불필요한 값 제거 * refactor: 고정 gap 제거 --- src/components/layout/BottomNav/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/BottomNav/index.tsx b/src/components/layout/BottomNav/index.tsx index 98da0508..f2733f7d 100644 --- a/src/components/layout/BottomNav/index.tsx +++ b/src/components/layout/BottomNav/index.tsx @@ -36,7 +36,7 @@ function BottomNavItem({ to, label, Icon, end = false }: BottomNavItemConfig) { function BottomNav() { return (