Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added e2e/fixtures/test-video.mp4
Binary file not shown.
20 changes: 15 additions & 5 deletions e2e/pages/banner-detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ export class BannerDetailPage extends BasePage {

readonly endDateInput = () => this.page.locator('input#endDate');

readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]');
readonly mediaFileInput = () => this.page.locator('input[type="file"]');

readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]');

readonly uploadedVideo = () => this.page.locator('video');

readonly uploadedMedia = () => this.page.locator('img[alt="업로드된 이미지"], video');

readonly deleteDialog = () => this.page.getByRole('alertdialog');

readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' });
Expand Down Expand Up @@ -148,13 +152,19 @@ export class BannerDetailPage extends BasePage {

async uploadTestImage() {
const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png');
await this.imageFileInput().setInputFiles(testImagePath);
await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 });
await this.mediaFileInput().setInputFiles(testImagePath);
await this.uploadedMedia().waitFor({ state: 'visible', timeout: 10000 });
}

async uploadTestVideo() {
const testVideoPath = path.resolve(__dirname, '../fixtures/test-video.mp4');
await this.mediaFileInput().setInputFiles(testVideoPath);
await this.uploadedVideo().waitFor({ state: 'visible', timeout: 10000 });
}

async ensureImage() {
const hasImage = await this.uploadedImage().isVisible().catch(() => false);
if (!hasImage) {
const hasMedia = await this.uploadedMedia().isVisible().catch(() => false);
if (!hasMedia) {
await this.uploadTestImage();
}
}
Expand Down
30 changes: 30 additions & 0 deletions e2e/specs/banners.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,36 @@ test.describe('배너 CRUD 플로우', () => {
});
});

test.describe('배너 동영상 업로드', () => {
test('동영상 파일 업로드 시 비디오 미리보기가 표시된다', async ({ page }) => {
const detailPage = new BannerDetailPage(page);

await detailPage.gotoNew();

// 동영상 업로드
await detailPage.uploadTestVideo();

// <video> 태그로 미리보기가 표시되는지 확인
await expect(detailPage.uploadedVideo()).toBeVisible();
// <img> 태그는 표시되지 않아야 함
await expect(detailPage.uploadedImage()).not.toBeVisible();
});

test('이미지 파일 업로드 시 이미지 미리보기가 표시된다', async ({ page }) => {
const detailPage = new BannerDetailPage(page);

await detailPage.gotoNew();

// 이미지 업로드
await detailPage.uploadTestImage();

// <img> 태그로 미리보기가 표시되는지 확인
await expect(detailPage.uploadedImage()).toBeVisible();
// <video> 태그는 표시되지 않아야 함
await expect(detailPage.uploadedVideo()).not.toBeVisible();
});
});

test.describe('배너 순서 변경', () => {
let listPage: BannerListPage;

Expand Down
93 changes: 84 additions & 9 deletions src/components/common/ImageUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,97 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';

/** 허용 파일 타입 */
const DEFAULT_ACCEPT = 'image/*';

/**
* 파일 MIME 타입이 허용 목록에 포함되는지 확인
* @param fileType - 파일의 MIME 타입 (예: 'image/webp', 'video/mp4')
* @param accept - accept 속성 문자열 (예: 'image/*,video/mp4')
*/
export function isFileTypeAllowed(fileType: string, accept: string): boolean {
return accept.split(',').some((pattern) => {
const trimmed = pattern.trim();
if (trimmed.endsWith('/*')) {
return fileType.startsWith(trimmed.replace('/*', '/'));
}
return fileType === trimmed;
});
}

/** 파일이 비디오 타입인지 확인 */
export function isVideoFile(file: File | null): boolean;
export function isVideoFile(url: string | null): boolean;
export function isVideoFile(input: File | string | null): boolean {
if (!input) return false;
if (typeof input === 'string') {
return input.match(/\.(mp4|webm|mov)(\?|$)/i) !== null;
}
return input.type.startsWith('video/');
}

/**
* ImageUpload 컴포넌트의 props
* @param imageUrl - 현재 이미지 URL (초기값 또는 서버에서 로드된 값)
* @param onImageChange - 이미지 변경 시 호출되는 콜백
* @param imageUrl - 현재 미디어 URL (초기값 또는 서버에서 로드된 값)
* @param onImageChange - 미디어 변경 시 호출되는 콜백
* @param minHeight - 최소 높이 (기본: 200px)
* @param accept - 허용 파일 타입 (기본: 'image/*')
* @param onFileRejected - 허용되지 않은 파일 업로드 시 콜백
* @param description - 업로드 영역 안내 텍스트
* @param supportText - 지원 포맷 안내 텍스트
*/
export interface ImageUploadProps {
imageUrl: string | null;
onImageChange: (file: File | null, previewUrl: string | null) => void;
minHeight?: number;
accept?: string;
onFileRejected?: (file: File) => void;
description?: string;
supportText?: string;
}

export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageUploadProps) {
export function ImageUpload({
imageUrl,
onImageChange,
minHeight = 200,
accept = DEFAULT_ACCEPT,
onFileRejected,
description = '이미지를 드래그하거나 클릭하여 업로드',
supportText = 'PNG, JPG, WEBP 지원',
}: ImageUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(imageUrl);
const [isVideo, setIsVideo] = useState(() => isVideoFile(imageUrl));
const fileInputRef = useRef<HTMLInputElement>(null);
const blobUrlRef = useRef<string | null>(null);

useEffect(() => {
setPreviewUrl(imageUrl);
if (!imageUrl) {
setIsVideo(false);
} else if (!imageUrl.startsWith('blob:')) {
// CDN URL은 확장자로 판별, blob URL은 handleFile에서 설정한 값을 유지
setIsVideo(isVideoFile(imageUrl));
}
}, [imageUrl]);

const handleFile = useCallback(
(file: File) => {
if (!file.type.startsWith('image/')) {
if (!isFileTypeAllowed(file.type, accept)) {
onFileRejected?.(file);
return;
}

if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
setIsVideo(file.type.startsWith('video/'));
const url = URL.createObjectURL(file);
blobUrlRef.current = url;
setPreviewUrl(url);
onImageChange(file, url);
Comment on lines +96 to 100
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] URL.createObjectURL로 만든 blob URL을 URL.revokeObjectURL로 정리하지 않아 파일을 여러 번 교체/삭제하면 메모리가 누적될 수 있습니다.

배너 등록/수정 페이지에서 미디어를 여러 번 바꾸는 경우 탭 메모리가 계속 증가할 수 있으니, 새 blob URL 생성 전에 이전 URL을 revoke하고 unmount/삭제 시에도 revoke하도록 cleanup을 추가해 주세요.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e07e49f.

변경 내용: blobUrlRef로 현재 blob URL을 추적하고, 파일 교체 시 이전 blob URL을 URL.revokeObjectURL로 정리합니다. 삭제 시에도 동일하게 cleanup합니다.
이유: 파일을 여러 번 교체하면 blob URL이 누적되어 메모리 누수가 발생할 수 있습니다.

},
[onImageChange]
[accept, onFileRejected, onImageChange]
);

const handleDragOver = (e: React.DragEvent) => {
Expand Down Expand Up @@ -71,7 +130,12 @@ export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageU
};

const handleRemove = () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
setPreviewUrl(null);
setIsVideo(false);
onImageChange(null, null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
Expand All @@ -82,7 +146,18 @@ export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageU
<div className="space-y-4">
{previewUrl ? (
<div className="relative">
<img src={previewUrl} alt="업로드된 이미지" className="w-full rounded-lg border" />
{isVideo ? (
<video
src={previewUrl}
className="w-full rounded-lg border"
controls
muted
loop
playsInline
/>
) : (
<img src={previewUrl} alt="업로드된 이미지" className="w-full rounded-lg border" />
)}
<Button
type="button"
variant="destructive"
Expand All @@ -107,14 +182,14 @@ export function ImageUpload({ imageUrl, onImageChange, minHeight = 200 }: ImageU
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mb-2 h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">이미지를 드래그하거나 클릭하여 업로드</p>
<p className="mt-1 text-xs text-muted-foreground">PNG, JPG, WEBP 지원</p>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="mt-1 text-xs text-muted-foreground">{supportText}</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept={accept}
className="hidden"
onChange={handleFileSelect}
/>
Expand Down
Loading
Loading