fix: 영상 업로드가 실패한 채로 저장되지 않게 막는다
"등록은 잘 된다"는 것이 문제였다. 영상 업로드가 413으로 실패해도 화면은 오류만 띄우고 저장 버튼은 그대로 눌렸다. 영상은 필수 항목이 아니라 검증도 통과하고, `videoFileId`가 빈 값인 채로 저장돼 **영상 없는 콘텐츠가 조용히 만들어졌다.** 사용자에게는 정상 등록으로 보인다. 고른 영상이 끝내 올라가지 않으면 그 사실을 `videoUploadFailed`로 실어 보내고 Server Action이 저장을 막는다 — 업로드 중(`videoUploading`)을 막던 것과 같은 방식이다. 파일을 지우면 플래그와 오류가 함께 풀려 영상 없이 저장하려는 의도는 그대로 통한다. 413 메시지 자체는 정확하다(재실측: 2MB·6MB 모두 nginx 413). 인프라 상향 전까지는 이 경로로 영상을 올릴 수 없고, 그 사실이 저장 시점에 드러나는 편이 낫다. 확인: 100MB 초과 선택 → 오류 + `videoUploadFailed=1` + `videoFileId` 빈 값, 삭제 → 플래그·오류 모두 해제. Co-Authored-By: Claude Opus 5
@557130ca20e03a49d3733a1273a7f29cee1c123b
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
... | ... | @@ -48,6 +48,8 @@ |
| 48 | 48 |
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
| 49 | 49 |
const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
| 50 | 50 |
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; |
| 51 |
+const VIDEO_FAILED_MESSAGE = |
|
| 52 |
+ '영상을 올리지 못했습니다. 파일을 지우거나 다시 골라 주세요 — 이대로 저장하면 영상 없이 저장됩니다.'; |
|
| 51 | 53 |
const VIDEO_UPLOADING_MESSAGE = |
| 52 | 54 |
'영상을 올리는 중입니다. 업로드가 끝난 뒤 저장해 주세요.'; |
| 53 | 55 |
const OVERSIZE_MESSAGE = `첨부파일은 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
|
... | ... | @@ -228,6 +230,9 @@ |
| 228 | 230 |
if (readString(formData, 'videoUploading') === '1') {
|
| 229 | 231 |
return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
|
| 230 | 232 |
} |
| 233 |
+ if (readString(formData, 'videoUploadFailed') === '1') {
|
|
| 234 |
+ return { status: 'error', message: VIDEO_FAILED_MESSAGE };
|
|
| 235 |
+ } |
|
| 231 | 236 |
|
| 232 | 237 |
const failed = await runWrite(async () => {
|
| 233 | 238 |
const validation = validateShortsForm( |
... | ... | @@ -269,6 +274,9 @@ |
| 269 | 274 |
if (readString(formData, 'videoUploading') === '1') {
|
| 270 | 275 |
return { status: 'error', message: VIDEO_UPLOADING_MESSAGE };
|
| 271 | 276 |
} |
| 277 |
+ if (readString(formData, 'videoUploadFailed') === '1') {
|
|
| 278 |
+ return { status: 'error', message: VIDEO_FAILED_MESSAGE };
|
|
| 279 |
+ } |
|
| 272 | 280 |
|
| 273 | 281 |
const failed = await runWrite(async () => {
|
| 274 | 282 |
const validation = validateShortsForm( |
--- app/(protected)/(basic)/contents/_components/content-video-field.tsx
+++ app/(protected)/(basic)/contents/_components/content-video-field.tsx
... | ... | @@ -33,6 +33,9 @@ |
| 33 | 33 |
const [fileId, setFileId] = useState(defaultFileId ?? ''); |
| 34 | 34 |
const [isUploading, setIsUploading] = useState(false); |
| 35 | 35 |
const [error, setError] = useState('');
|
| 36 |
+ // 고른 영상이 끝내 올라가지 않은 상태. 이대로 저장하면 영상 없이 저장되므로 Server Action이 |
|
| 37 |
+ // 막게 한다 — 화면에 오류가 떠 있어도 저장 버튼은 눌리기 때문이다. |
|
| 38 |
+ const [hasFailedPick, setHasFailedPick] = useState(false); |
|
| 36 | 39 |
|
| 37 | 40 |
async function upload(picked: File[]) {
|
| 38 | 41 |
const file = picked[0]; |
... | ... | @@ -41,8 +44,10 @@ |
| 41 | 44 |
} |
| 42 | 45 |
|
| 43 | 46 |
setError('');
|
| 47 |
+ setHasFailedPick(false); |
|
| 44 | 48 |
|
| 45 | 49 |
if (!isAllowedFileExtension(FILE_MODULE.vodContent, file.name)) {
|
| 50 |
+ setHasFailedPick(true); |
|
| 46 | 51 |
setError( |
| 47 | 52 |
`${allowedExtensions(FILE_MODULE.vodContent).join(', ')} 파일만 올릴 수 있습니다.`
|
| 48 | 53 |
); |
... | ... | @@ -50,6 +55,7 @@ |
| 50 | 55 |
} |
| 51 | 56 |
// 넘겨 보내면 앞단이 사유 없는 413으로 끊는다 — 고르는 순간 이유를 말해 준다. |
| 52 | 57 |
if (file.size > MAX_STREAM_UPLOAD_BYTES) {
|
| 58 |
+ setHasFailedPick(true); |
|
| 53 | 59 |
setError(`영상은 ${MAX_SIZE_TEXT} 이하만 올릴 수 있습니다.`);
|
| 54 | 60 |
return; |
| 55 | 61 |
} |
... | ... | @@ -62,11 +68,13 @@ |
| 62 | 68 |
const response = await fetch(UPLOAD_ENDPOINT, { method: 'POST', body });
|
| 63 | 69 |
const payload = (await response.json()) as { id?: string; message?: string };
|
| 64 | 70 |
if (!response.ok || !payload.id) {
|
| 71 |
+ setHasFailedPick(true); |
|
| 65 | 72 |
setError(payload.message ?? '영상을 업로드하지 못했습니다.'); |
| 66 | 73 |
return; |
| 67 | 74 |
} |
| 68 | 75 |
setFileId(payload.id); |
| 69 | 76 |
} catch {
|
| 77 |
+ setHasFailedPick(true); |
|
| 70 | 78 |
setError('영상을 업로드하지 못했습니다.');
|
| 71 | 79 |
} finally {
|
| 72 | 80 |
setIsUploading(false); |
... | ... | @@ -83,10 +91,15 @@ |
| 83 | 91 |
description={UPLOAD_HELP_TEXT}
|
| 84 | 92 |
disabled={isUploading}
|
| 85 | 93 |
onSelect={upload}
|
| 86 |
- onRemove={() => setFileId('')}
|
|
| 94 |
+ onRemove={() => {
|
|
| 95 |
+ setFileId('');
|
|
| 96 |
+ setError('');
|
|
| 97 |
+ setHasFailedPick(false); |
|
| 98 |
+ }} |
|
| 87 | 99 |
/> |
| 88 | 100 |
<input type="hidden" name="videoFileId" value={fileId} />
|
| 89 | 101 |
{isUploading && <input type="hidden" name="videoUploading" value="1" />}
|
| 102 |
+ {hasFailedPick && <input type="hidden" name="videoUploadFailed" value="1" />}
|
|
| 90 | 103 |
|
| 91 | 104 |
{isUploading && (
|
| 92 | 105 |
<FoxHelperText type="information" message="영상을 올리는 중입니다…" /> |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?