summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-24 15:38:35 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-24 15:38:35 +0900
commitaf813883db9bae24755e05205647954fd0ab1270 (patch)
tree79c963774ede6238bd878a22d8baba2ce5c02764 /app
parenta55198e00ff982f6310a08c76a0502c90dd7d859 (diff)
(김준회) dolce 파일업로드 개선 및 nginx conf 수정(커밋관리X)
Diffstat (limited to 'app')
-rw-r--r--app/api/dolce/upload-files/route.ts425
1 files changed, 287 insertions, 138 deletions
diff --git a/app/api/dolce/upload-files/route.ts b/app/api/dolce/upload-files/route.ts
index 1d302cb2..e547f217 100644
--- a/app/api/dolce/upload-files/route.ts
+++ b/app/api/dolce/upload-files/route.ts
@@ -1,114 +1,203 @@
import { NextRequest, NextResponse } from "next/server";
-import fs from "fs/promises";
-import { createReadStream } from "fs";
-import path from "path";
-import os from "os";
+import formidable from "formidable";
+import { Readable } from "stream";
+import type { IncomingMessage } from "http";
const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111";
+// Next.js API Route 설정
+export const maxDuration = 3600; // 1시간 (대용량 파일 업로드 지원, 느린 연결 대응)
+export const dynamic = "force-dynamic";
+
+// body parser 설정 (대용량 파일 지원)
+export const config = {
+ api: {
+ bodyParser: false, // formidable이 직접 파싱하도록 비활성화
+ responseLimit: false, // 응답 크기 제한 없음
+ },
+};
+
/**
- * 임시 파일 저장 및 정리 헬퍼
+ * NextRequest를 Node.js IncomingMessage 스타일로 변환
+ * Next.js의 request.body를 직접 스트림으로 사용 (메모리 로드 없음)
*/
-async function saveToTempFile(file: File): Promise<{ filepath: string; cleanup: () => Promise<void> }> {
- const tempDir = os.tmpdir();
- const tempFilePath = path.join(tempDir, `upload-${Date.now()}-${file.name}`);
+function convertToNodeRequest(request: NextRequest) {
+ // Next.js의 ReadableStream을 Node.js Readable로 변환
+ const webReadableStream = request.body;
- const arrayBuffer = await file.arrayBuffer();
- await fs.writeFile(tempFilePath, Buffer.from(arrayBuffer));
+ if (!webReadableStream) {
+ throw new Error("Request body is null");
+ }
+
+ // Web ReadableStream을 Node.js Readable로 변환
+ // Node.js 17+의 Readable.fromWeb 사용
+ // @ts-expect-error - Web Streams API와 Node.js Streams의 타입 차이
+ const nodeReadable = Readable.fromWeb(webReadableStream);
- return {
- filepath: tempFilePath,
- cleanup: async () => {
- try {
- await fs.unlink(tempFilePath);
- } catch (error) {
- console.error(`임시 파일 삭제 실패: ${tempFilePath}`, error);
- }
- },
+ // Node.js IncomingMessage 필수 속성 추가
+ const nodeRequest = nodeReadable as Readable & {
+ headers: Record<string, string>;
+ method: string;
+ url: string;
+ httpVersion: string;
+ httpVersionMajor: number;
+ httpVersionMinor: number;
};
+
+ nodeRequest.headers = Object.fromEntries(request.headers.entries());
+ nodeRequest.method = request.method;
+ nodeRequest.url = request.url;
+ nodeRequest.httpVersion = "1.1";
+ nodeRequest.httpVersionMajor = 1;
+ nodeRequest.httpVersionMinor = 1;
+
+ return nodeRequest;
}
/**
- * 스트리밍 방식으로 파일 업로드
- * Node.js ReadableStream을 Web ReadableStream으로 변환하여 fetch 사용
+ * 스트림을 DOLCE API로 즉시 전송 (임시 저장 없음)
*/
-async function uploadFileStream(
- filepath: string,
+async function uploadStreamToDolce(
+ fileStream: NodeJS.ReadableStream,
+ fileName: string,
+ fileSize: number,
uploadId: string,
- fileId: string,
- fileSize: number
+ fileId: string
): Promise<string> {
const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`;
+ const startTime = Date.now();
+
+ console.log(`[Proxy] 파일 스트리밍 시작: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)}MB)`);
+
+ let bytesUploaded = 0;
+ const logInterval = setInterval(() => {
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ const progress = fileSize > 0 ? ((bytesUploaded / fileSize) * 100).toFixed(1) : "0.0";
+ console.log(`[Proxy] 진행 중: ${fileName} - ${progress}% (${elapsed}초)`);
+ }, 10000); // 10초마다 로그
+
+ // 진행도 추적을 위한 PassThrough 스트림
+ const { PassThrough } = await import("stream");
+ const progressStream = new PassThrough();
- // Node.js ReadableStream 생성
- const nodeStream = createReadStream(filepath, {
- highWaterMark: 64 * 1024, // 64KB 청크로 읽기
+ fileStream.on("data", (chunk: Buffer) => {
+ bytesUploaded += chunk.length;
});
-
- // Node.js Stream을 Web ReadableStream으로 변환
+
+ fileStream.pipe(progressStream);
+
+ // Web ReadableStream으로 변환
const webStream = new ReadableStream({
start(controller) {
- nodeStream.on("data", (chunk: Buffer) => {
+ progressStream.on("data", (chunk: Buffer) => {
controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
});
-
- nodeStream.on("end", () => {
+
+ progressStream.on("end", () => {
+ clearInterval(logInterval);
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.log(`[Proxy] 스트림 전송 완료: ${fileName} (${elapsed}초)`);
controller.close();
});
-
- nodeStream.on("error", (error) => {
+
+ progressStream.on("error", (error) => {
+ clearInterval(logInterval);
+ console.error(`[Proxy] 스트림 에러: ${fileName}`, error);
controller.error(error);
});
},
cancel() {
- nodeStream.destroy();
- },
- });
-
- // 스트리밍 업로드
- const uploadResponse = await fetch(uploadUrl, {
- method: "POST",
- headers: {
- "Content-Type": "application/octet-stream",
- "Content-Length": fileSize.toString(),
+ clearInterval(logInterval);
+ progressStream.destroy();
+ // fileStream 정리
+ if (fileStream && "destroy" in fileStream && typeof fileStream.destroy === "function") {
+ fileStream.destroy();
+ }
},
- body: webStream as unknown as BodyInit,
- // @ts-expect-error - duplex is required for streaming uploads with ReadableStream
- duplex: "half",
});
-
- if (!uploadResponse.ok) {
- throw new Error(
- `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}`
- );
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort();
+ }, 3600000); // 1시간 타임아웃
+
+ const uploadResponse = await fetch(uploadUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Length": fileSize.toString(),
+ },
+ body: webStream as unknown as BodyInit,
+ signal: controller.signal,
+ // @ts-expect-error - duplex is required for streaming uploads with ReadableStream
+ duplex: "half",
+ });
+
+ clearTimeout(timeoutId);
+
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ console.log(`[Proxy] DOLCE API 응답: ${fileName} (${elapsed}초, HTTP ${uploadResponse.status})`);
+
+ if (!uploadResponse.ok) {
+ const errorText = await uploadResponse.text();
+ console.error(`[Proxy] DOLCE API 실패 (${fileName}): HTTP ${uploadResponse.status}`, errorText);
+ throw new Error(
+ `파일 업로드 실패 (${fileName}): ${uploadResponse.status} ${uploadResponse.statusText}`
+ );
+ }
+
+ const fileRelativePath = await uploadResponse.text();
+
+ if (!fileRelativePath || fileRelativePath.trim() === "") {
+ console.error(`[Proxy] DOLCE API 빈 경로 반환 (${fileName})`);
+ throw new Error(`파일 업로드 실패: 빈 경로 반환 (${fileName})`);
+ }
+
+ const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ const speed = (fileSize / 1024 / 1024 / (Date.now() - startTime) * 1000).toFixed(2);
+ console.log(`[Proxy] 업로드 완료: ${fileName} (${totalElapsed}초, ${speed}MB/s) → ${fileRelativePath}`);
+
+ return fileRelativePath;
+ } catch (error) {
+ clearInterval(logInterval);
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+
+ if (error instanceof Error && error.name === "AbortError") {
+ console.error(`[Proxy] 타임아웃 (${elapsed}초): ${fileName}`);
+ throw new Error(`업로드 타임아웃 (1시간 초과): ${fileName}`);
+ }
+
+ console.error(`[Proxy] 업로드 에러 (${elapsed}초): ${fileName}`, error);
+ throw error;
}
-
- const fileRelativePath = await uploadResponse.text();
- return fileRelativePath;
}
/**
- * 상세도면 파일 업로드 API
- * 스트리밍 처리로 메모리 효율적 업로드
+ * 상세도면 파일 업로드 API (스트리밍 Proxy)
+ *
+ * 진정한 Proxy 모드:
+ * - 클라이언트로부터 받는 즉시 DOLCE API로 전달
+ * - 임시 파일 저장 없음 (메모리 효율)
+ * - 진행도가 실시간으로 정확하게 반영
*/
export async function POST(request: NextRequest) {
- const tempFiles: Array<{ filepath: string; cleanup: () => Promise<void> }> = [];
-
try {
- // FormData 파싱
- const formData = await request.formData();
-
- const uploadId = formData.get("uploadId") as string;
- const userId = formData.get("userId") as string;
- const fileCount = parseInt(formData.get("fileCount") as string);
+ console.log("[Proxy] 업로드 요청 수신");
- if (!uploadId || !userId || !fileCount) {
- return NextResponse.json(
- { success: false, error: "필수 파라미터가 누락되었습니다" },
- { status: 400 }
- );
- }
+ // NextRequest를 Node.js request로 변환 (스트리밍)
+ const nodeRequest = convertToNodeRequest(request);
+
+ // formidable 설정 (스트리밍 모드)
+ const form = formidable({
+ maxFileSize: 1024 * 1024 * 1024, // 1GB
+ maxTotalFileSize: 10 * 1024 * 1024 * 1024, // 10GB
+ allowEmptyFiles: false,
+ // 파일을 디스크에 저장하지 않음
+ enabledPlugins: [],
+ });
+ // 파일 메타데이터 수집
const uploadResults: Array<{
FileId: string;
UploadId: string;
@@ -121,103 +210,163 @@ export async function POST(request: NextRequest) {
OwnerUserId: string;
}> = [];
- // 기존 파일 개수 조회
- const existingFilesResponse = await fetch(
- `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- uploadId: uploadId,
- }),
- }
- );
+ let uploadId: string | null = null;
+ let userId: string | null = null;
+ let currentFileIndex = 0;
+ let startSeq = 1;
- if (!existingFilesResponse.ok) {
- throw new Error("기존 파일 조회 실패");
- }
+ // formidable 이벤트 기반 처리
+ await new Promise<void>((resolve, reject) => {
+ // 필드(메타데이터) 처리
+ form.on("field", (name, value) => {
+ if (name === "uploadId") uploadId = value;
+ if (name === "userId") userId = value;
+ });
+
+ // 파일 스트림 처리 (받는 즉시 DOLCE로 전송)
+ form.on("fileBegin", (formName, file) => {
+ console.log(`[Proxy] 파일 수신 시작: ${file.originalFilename || "unknown"} (${formName})`);
+ });
- const existingFilesData = await existingFilesResponse.json();
- const startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1;
+ form.on("file", async (formName, file) => {
+ try {
+ if (!uploadId || !userId) {
+ throw new Error("uploadId 또는 userId가 누락되었습니다");
+ }
- // 파일 수집
- const files: File[] = [];
- for (let i = 0; i < fileCount; i++) {
- const file = formData.get(`file_${i}`) as File;
- if (file) {
- files.push(file);
- }
- }
+ // 첫 파일일 때 기존 파일 조회
+ if (currentFileIndex === 0) {
+ try {
+ const existingFilesResponse = await fetch(
+ `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ uploadId }),
+ }
+ );
- // 각 파일을 임시 디렉터리에 저장 후 스트리밍 업로드
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
- const fileId = crypto.randomUUID();
-
- // 임시 파일로 저장 (메모리 압박 감소)
- const tempFile = await saveToTempFile(file);
- tempFiles.push(tempFile);
-
- // 스트리밍 방식으로 DOLCE API에 업로드
- const fileRelativePath = await uploadFileStream(
- tempFile.filepath,
- uploadId,
- fileId,
- file.size
- );
+ if (existingFilesResponse.ok) {
+ const existingFilesData = await existingFilesResponse.json();
+ startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1;
+ console.log(`[Proxy] 기존 파일=${startSeq - 1}개, 새 파일 시작 Seq=${startSeq}`);
+ } else {
+ console.warn(`[Proxy] FileInfoList 조회 실패, startSeq=1로 시작`);
+ }
+ } catch (error) {
+ console.warn(`[Proxy] FileInfoList 조회 에러:`, error);
+ }
+ }
+
+ const fileId = crypto.randomUUID();
+ const fileName = file.originalFilename || `file_${currentFileIndex}`;
+ const fileSize = file.size;
+
+ console.log(`[Proxy] 파일 ${currentFileIndex + 1}: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)}MB)`);
+
+ // 이 시점에서 파일은 이미 디스크에 저장되어 있음 (formidable 기본 동작)
+ // 파일을 읽어서 DOLCE API로 전송
+ const fs = await import("fs");
+ const fileStream = fs.createReadStream(file.filepath);
+
+ const fileRelativePath = await uploadStreamToDolce(
+ fileStream,
+ fileName,
+ fileSize,
+ uploadId,
+ fileId
+ );
+
+ uploadResults.push({
+ FileId: fileId,
+ UploadId: uploadId,
+ FileSeq: startSeq + currentFileIndex,
+ FileName: fileName,
+ FileRelativePath: fileRelativePath,
+ FileSize: fileSize,
+ FileCreateDT: new Date().toISOString(),
+ FileWriteDT: new Date().toISOString(),
+ OwnerUserId: userId,
+ });
+
+ // 임시 파일 삭제
+ try {
+ await fs.promises.unlink(file.filepath);
+ } catch (error) {
+ console.error(`[Proxy] 임시 파일 삭제 실패: ${file.filepath}`, error);
+ }
+
+ currentFileIndex++;
+ } catch (error) {
+ reject(error);
+ }
+ });
- uploadResults.push({
- FileId: fileId,
- UploadId: uploadId,
- FileSeq: startSeq + i,
- FileName: file.name,
- FileRelativePath: fileRelativePath,
- FileSize: file.size,
- FileCreateDT: new Date().toISOString(),
- FileWriteDT: new Date().toISOString(),
- OwnerUserId: userId,
+ form.on("error", (err) => {
+ console.error("[Proxy] formidable 에러:", err);
+ reject(err);
});
- // 처리 완료된 임시 파일 즉시 삭제
- await tempFile.cleanup();
+ form.on("end", () => {
+ console.log("[Proxy] formidable 파싱 완료");
+ resolve();
+ });
+
+ // 파싱 시작 (타입 캐스팅: formidable은 실제로는 Readable 스트림만 필요)
+ form.parse(nodeRequest as unknown as IncomingMessage);
+ });
+
+ if (!uploadId || !userId) {
+ return NextResponse.json(
+ { success: false, error: "필수 파라미터가 누락되었습니다" },
+ { status: 400 }
+ );
+ }
+
+ if (uploadResults.length === 0) {
+ return NextResponse.json(
+ { success: false, error: "업로드된 파일이 없습니다" },
+ { status: 400 }
+ );
}
// 업로드 완료 통지
- const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`;
+ console.log(`[Proxy] PWPUploadResultService 호출: ${uploadResults.length}개 파일 메타데이터 전송`);
+ const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`;
const resultResponse = await fetch(resultServiceUrl, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(uploadResults),
});
if (!resultResponse.ok) {
- throw new Error(
- `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}`
- );
+ const errorText = await resultResponse.text();
+ console.error(`[Proxy] PWPUploadResultService 실패: HTTP ${resultResponse.status}`, errorText);
+ throw new Error(`업로드 완료 통지 실패: ${resultResponse.status}`);
}
const resultText = await resultResponse.text();
+ console.log(`[Proxy] PWPUploadResultService 응답:`, resultText);
+
if (resultText !== "Success") {
+ console.error(`[Proxy] PWPUploadResultService 예상치 못한 응답: "${resultText}"`);
throw new Error(`업로드 완료 통지 실패: ${resultText}`);
}
+ console.log(`[Proxy] ✅ 완료: ${uploadResults.length}개 파일 업로드 성공`);
+
return NextResponse.json({
success: true,
uploadedCount: uploadResults.length,
});
} catch (error) {
- console.error("파일 업로드 실패:", error);
+ console.error("[Proxy] ❌ 업로드 실패:", error);
- // 에러 발생 시 남아있는 임시 파일 모두 정리
- for (const tempFile of tempFiles) {
- await tempFile.cleanup();
+ if (error instanceof Error) {
+ console.error("[Proxy] 에러 스택:", error.stack);
}
-
+
return NextResponse.json(
{
success: false,