From 013ddc697598a7d15c8ee70895420ba1a0249cc3 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 15:57:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B8=B0=EB=B3=B8=20=EC=A0=95=EB=A0=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=EB=AA=85=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 정렬 로직을 개선하여, `sortBy` 파라미터가 없을 경우 `created_date` 컬럼이 존재하는 경우에만 기본 정렬을 적용하도록 수정했습니다. - 안전한 테이블명 검증 로직을 추가하여, SQL 인젝션 공격을 방지하고 데이터베이스 쿼리의 안정성을 높였습니다. - 여러 위치에서 `created_date` 컬럼의 존재 여부를 확인하여, 일관된 정렬 기준을 유지하도록 개선했습니다. --- .../src/services/tableManagementService.ts | 33 ++- docs/image-file-storage-guide.md | 214 ++++++++++++++++++ 2 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 docs/image-file-storage-guide.md diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e62a541..db5f32ed 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2266,6 +2266,9 @@ export class TableManagementService { ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // 안전한 테이블명 검증 + const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); + // ORDER BY 조건 구성 let orderClause = ""; if (sortBy) { @@ -2274,13 +2277,16 @@ export class TableManagementService { sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC"; orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`; } else { - // sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시) - orderClause = `ORDER BY main.created_date DESC`; + // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + const hasCreatedDate = await query( + `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`, + [safeTableName] + ); + if (hasCreatedDate.length > 0) { + orderClause = `ORDER BY main.created_date DESC`; + } } - // 안전한 테이블명 검증 - const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); - // 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요) const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`; const countResult = await query(countQuery, searchValues); @@ -3188,10 +3194,13 @@ export class TableManagementService { } // ORDER BY 절 구성 - // sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시) + // sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용 + const hasCreatedDateColumn = selectColumns.includes("created_date"); const orderBy = options.sortBy ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` - : `main."created_date" DESC`; + : hasCreatedDateColumn + ? `main."created_date" DESC` + : ""; // 페이징 계산 const offset = (options.page - 1) * options.size; @@ -3401,6 +3410,7 @@ export class TableManagementService { const entitySearchColumns: string[] = []; // Entity 조인 쿼리 생성하여 별칭 매핑 얻기 + const hasCreatedDateForSearch = selectColumns.includes("created_date"); const joinQueryResult = entityJoinService.buildJoinQuery( tableName, joinConfigs, @@ -3408,7 +3418,9 @@ export class TableManagementService { "", // WHERE 절은 나중에 추가 options.sortBy ? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` - : `main."created_date" DESC`, + : hasCreatedDateForSearch + ? `main."created_date" DESC` + : undefined, options.size, (options.page - 1) * options.size ); @@ -3594,9 +3606,12 @@ export class TableManagementService { } const whereClause = whereConditions.join(" AND "); + const hasCreatedDateForOrder = selectColumns.includes("created_date"); const orderBy = options.sortBy ? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` - : `main."created_date" DESC`; + : hasCreatedDateForOrder + ? `main."created_date" DESC` + : ""; // 페이징 계산 const offset = (options.page - 1) * options.size; diff --git a/docs/image-file-storage-guide.md b/docs/image-file-storage-guide.md new file mode 100644 index 00000000..73c99baa --- /dev/null +++ b/docs/image-file-storage-guide.md @@ -0,0 +1,214 @@ +# 이미지/파일 저장 방식 가이드 + +## 개요 + +WACE 솔루션에서 이미지 및 파일은 **attach_file_info 테이블**에 메타데이터를 저장하고, 실제 파일은 **서버 디스크**에 저장하는 이중 구조를 사용합니다. + +--- + +## 1. 데이터 흐름 + +``` +[사용자 업로드] → [백엔드 API] → [디스크 저장] + [DB 메타데이터 저장] + ↓ ↓ + /uploads/COMPANY_7/ attach_file_info 테이블 + 2026/02/06/ (objid, file_path, ...) + 1770346704685_5.png +``` + +### 저장 과정 + +1. 사용자가 파일 업로드 → `POST /api/files/upload` +2. 백엔드가 파일을 디스크에 저장: `/uploads/{company_code}/{YYYY}/{MM}/{DD}/{timestamp}_{filename}` +3. `attach_file_info` 테이블에 메타데이터 INSERT (objid, file_path, target_objid 등) +4. 비즈니스 테이블의 이미지 컬럼에 **파일 objid** 저장 (예: `item_info.image = '433765011963536400'`) + +### 조회 과정 + +1. 비즈니스 테이블에서 이미지 컬럼 값(objid) 로드 +2. `GET /api/files/preview/{objid}` 로 이미지 프리뷰 요청 +3. 백엔드가 `attach_file_info`에서 objid로 파일 정보 조회 +4. 디스크에서 실제 파일을 읽어 응답 + +--- + +## 2. 테이블 구조 + +### attach_file_info (파일 메타데이터) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| objid | numeric | 파일 고유 ID (PK, 큰 숫자) | +| real_file_name | varchar | 원본 파일명 | +| saved_file_name | varchar | 저장된 파일명 (timestamp_원본명) | +| file_path | varchar | 저장 경로 (/uploads/COMPANY_7/2026/02/06/...) | +| file_ext | varchar | 파일 확장자 | +| file_size | numeric | 파일 크기 (bytes) | +| target_objid | varchar | 연결 대상 (아래 패턴 참조) | +| company_code | varchar | 회사 코드 (멀티테넌시) | +| status | varchar | 상태 (ACTIVE, DELETED) | +| writer | varchar | 업로더 ID | +| regdate | timestamp | 등록일시 | +| is_representative | boolean | 대표 이미지 여부 | + +### 비즈니스 테이블 (예: item_info, company_mng) + +이미지 컬럼에 `attach_file_info.objid` 값을 문자열로 저장합니다. + +```sql +-- item_info.image = '433765011963536400' +-- company_mng.company_image = '413276787660035200' +``` + +--- + +## 3. target_objid 패턴 + +`attach_file_info.target_objid`는 파일이 어디에 연결되어 있는지를 나타냅니다. + +| 패턴 | 예시 | 설명 | +|------|------|------| +| 템플릿 모드 | `screen_files:140:comp_z4yffowb:image` | 화면 설계 시 업로드 (screenId:componentId:columnName) | +| 레코드 모드 | `item_info:uuid-xxx:image` | 특정 레코드에 연결 (tableName:recordId:columnName) | + +--- + +## 4. 파일 조회 API + +### GET /api/files/preview/{objid} + +이미지 프리뷰 (공개 접근 허용). + +``` +GET /api/files/preview/433765011963536400 +→ 200 OK (이미지 바이너리) +``` + +**주의: objid를 parseInt()로 변환하면 안 됩니다.** JavaScript의 `Number.MAX_SAFE_INTEGER`(9007199254740991)를 초과하는 큰 숫자이므로 **정밀도 손실**이 발생합니다. 반드시 **문자열**로 전달해야 합니다. + +```typescript +// 잘못된 방법 +const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [parseInt(objid)]); +// → parseInt("433765011963536400") = 433765011963536416 (16 차이!) +// → DB에서 찾을 수 없음 → 404 + +// 올바른 방법 +const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [objid]); +// → PostgreSQL이 문자열 → numeric 자동 캐스팅 +``` + +### GET /api/files/component-files + +컴포넌트별 파일 목록 조회 (인증 필요). + +``` +GET /api/files/component-files?screenId=149&componentId=comp_z4yffowb&tableName=item_info&recordId=uuid-xxx&columnName=image +``` + +**조회 우선순위:** +1. **데이터 파일**: `target_objid = '{tableName}:{recordId}:{columnName}'` 패턴으로 조회 +2. **템플릿 파일**: `target_objid = 'screen_files:{screenId}:{componentId}:{columnName}'` 패턴으로 조회 +3. **레코드 컬럼 값 조회 (fallback)**: 위 두 방법으로 파일을 찾지 못하면, 비즈니스 테이블의 레코드에서 해당 컬럼 값(파일 objid)을 읽어 직접 조회 + +```sql +-- fallback: 레코드의 image 컬럼에 저장된 objid로 직접 조회 +SELECT "image" FROM "item_info" WHERE id = $1; +-- → '433765011963536400' +SELECT * FROM attach_file_info WHERE objid = '433765011963536400' AND status = 'ACTIVE'; +``` + +--- + +## 5. 프론트엔드 컴포넌트 + +### v2-file-upload (FileUploadComponent.tsx) + +현재 사용되는 V2 파일 업로드 컴포넌트입니다. + +**파일 경로**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx` + +#### 이미지 로드 방식 + +1. **formData의 컬럼 값으로 로드**: `formData[columnName]`에 파일 objid가 있으면 `/api/files/preview/{objid}`로 이미지 표시 +2. **getComponentFiles API로 로드**: target_objid 패턴으로 서버에서 파일 목록 조회 + +#### 상태 관리 + +- `uploadedFiles` state: 현재 표시 중인 파일 목록 +- `localStorage` 백업: `fileUpload_{componentId}_{columnName}` 키로 저장 +- `window.globalFileState`: 전역 파일 상태 (컴포넌트 간 동기화) + +#### 등록/수정 모드 구분 + +- **수정 모드** (isRecordMode=true, recordId 있음): localStorage/서버에서 기존 파일 복원 +- **등록 모드** (isRecordMode=false, recordId 없음): localStorage 복원 스킵, 빈 상태로 시작 +- **단일 폼 화면** (회사정보 등): `formData[columnName]`의 objid 값으로 이미지 자동 로드 + +### file-upload (레거시) + +**파일 경로**: `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx` + +V2MediaRenderer에서 사용하는 레거시 컴포넌트. v2-file-upload와 유사하지만 별도 파일입니다. + +### ImageWidget + +**파일 경로**: `frontend/components/screen/widgets/types/ImageWidget.tsx` + +단순 이미지 표시용 위젯. 파일 업로드 기능은 있으나, `getFullImageUrl()`로 URL을 변환하여 `` 태그로 직접 표시합니다. 파일 관리(목록, 삭제 등) 기능은 없습니다. + +--- + +## 6. 디스크 저장 구조 + +``` +backend-node/uploads/ +├── COMPANY_7/ # 회사별 격리 +│ ├── 2026/ +│ │ ├── 01/ +│ │ │ └── 08/ +│ │ │ └── 1767863580718_img.jpg +│ │ └── 02/ +│ │ └── 06/ +│ │ ├── 1770346704685_5.png +│ │ └── 1770352493105_5.png +├── COMPANY_9/ +│ └── ... +└── company_*/ # 최고 관리자 전용 + └── ... +``` + +--- + +## 7. 수정 이력 (2026-02-06) + +### parseInt 정밀도 손실 수정 + +**파일**: `backend-node/src/controllers/fileController.ts` + +`attach_file_info.objid`는 `numeric` 타입으로 `433765011963536400` 같은 매우 큰 숫자입니다. JavaScript의 `parseInt()`는 `Number.MAX_SAFE_INTEGER`(약 9 * 10^15)를 초과하면 정밀도 손실이 발생합니다. + +| objid (원본) | parseInt 결과 | 차이 | +|:---|:---|:---:| +| 396361999644927100 | 396361999644927104 | -4 | +| 433765011963536400 | 433765011963536384 | +16 | +| 1128460590844245000 | 1128460590844244992 | +8 | + +**수정**: `parseInt(objid)` → `objid` (문자열 직접 전달, 8곳) + +### getComponentFiles fallback 추가 + +**파일**: `backend-node/src/controllers/fileController.ts` + +수정 모달에서 이미지가 안 보이는 문제. `target_objid` 패턴이 일치하지 않을 때, 비즈니스 테이블의 레코드 컬럼 값으로 파일을 직접 조회하는 fallback 로직 추가. + +### v2-file-upload 등록 모드 파일 잔존 방지 + +**파일**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx` + +연속 등록 시 이전 등록의 이미지가 남아있는 문제. `loadComponentFiles`와 fallback 로직에서 등록 모드(recordId 없음)일 때 파일 복원을 스킵하도록 수정. + +### ORDER BY 기본 정렬 추가 + +**파일**: `backend-node/src/services/tableManagementService.ts` + +`sortBy` 파라미터가 없을 때 `ORDER BY created_date DESC`를 기본값으로 적용. 4곳 수정.