diff --git a/docs/S3 이미지 저장 흐름.md b/docs/S3 이미지 저장 흐름.md new file mode 100644 index 00000000..9bd29515 --- /dev/null +++ b/docs/S3 이미지 저장 흐름.md @@ -0,0 +1,197 @@ +# S3 이미지 저장 흐름 + +> Q.CAST Front 에서 이미지가 AWS S3 로 들어가는 모든 경로를 한 곳에 정리한 문서. +> 코드 단일 진실 공급원: `src/app/api/image/**/route.js` (Next.js App Router, 서버 라우트). + +## 0. 한눈에 보기 + +| # | 경로 | 메서드 | 용도 | S3 키 패턴 | +|---|---|---|---|---| +| 1 | `/api/image/upload` | POST / DELETE | 사용자가 올린 참조 이미지 (PNG/JPG/...) | `upload/{원본파일명}` | +| 2 | `/api/image/cad` | POST / DELETE | DWG/DXF → PNG 변환 후 업로드 | `cads/{변환파일명}` | +| 3 | `/api/image/map` | GET / DELETE | 구글 정적지도 → S3 저장 | `maps/{fileNm}` | +| 4 | `/api/image/canvas` | POST | 캔버스 도면 캡처 → autocrop → 2벌 리사이즈 → 업로드 | `Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}[ _full ].png` | +| 5 | `/api/image/estimate-image-copy` | POST | 견적 이미지(1,2번) S3 내부 복사 | `Drawing/{S3_PROFILE}/{newObjectNo}_{newPlanNo}_{1,2}.png` | + +모든 라우트는 동일하게 `@aws-sdk/client-s3` 의 `S3Client` 를 모듈 로드 시점에 한 번 생성한다. 인증/리전/버킷은 환경변수로만 주입한다. + +## 1. 환경 변수 + +서버 라우트(`src/app/api/image/**/route.js`) 에서 직접 참조: + +| 변수 | 용도 | +|---|---| +| `AMPLIFY_BUCKET` | 버킷명. 응답 URL 도 이 값으로 조립 (`https://{AMPLIFY_BUCKET}.s3.{AWS_REGION}.amazonaws.com/{Key}`) | +| `AWS_REGION` | S3Client 리전 | +| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | IAM 자격증명 | +| `S3_PROFILE` | `Drawing/{S3_PROFILE}/...` 의 환경 prefix (e.g. local / dev / prd1) | + +클라이언트(`src/hooks/common/useRefFiles.js` 등) 에서 참조: + +| 변수 | 용도 | +|---|---| +| `NEXT_PUBLIC_AWS_S3_BASE_URL` | CloudFront(또는 대체) 도메인. 서버가 반환한 `fileName` 을 이 도메인에 붙여 캔버스 배경 URL 로 사용 | +| `NEXT_PUBLIC_CONVERTER_DWG_API_URL` / `NEXT_PUBLIC_CONVERTER_DXF_API_URL` | DWG/DXF → Base64 PNG 변환 외부 API | + +> 서버 응답에는 `https://{AMPLIFY_BUCKET}.s3.{AWS_REGION}.amazonaws.com/...` 의 raw S3 URL 이 담기지만, 캔버스에 배경으로 로드할 때는 `NEXT_PUBLIC_AWS_S3_BASE_URL`(CloudFront) 도메인 + `fileName` 조합으로 다시 만든다. 참조: `useRefFiles.js` 의 `setCurrentBgImage` 호출들. + +## 2. 라우트별 흐름 + +### 2.1 `/api/image/upload` — 참조 이미지 업로드/삭제 + +파일: `src/app/api/image/upload/route.js` + +- **POST**: `multipart/form-data` 의 `file` 한 개를 받아 `upload/{file.name}` 로 `PutObjectCommand`. 응답: `{ filePath, fileName, message }`. +- **DELETE**: `?fileName=...` 쿼리로 `upload/{fileName}` 키를 `DeleteObjectCommand`. + +특이사항: +- `ContentType` 을 `file.ContentType` 으로 넣지만 Web `File`/`Blob` 객체에는 `ContentType`(파스칼케이스) 속성이 없어 실제로는 `undefined`. 정상 케이스에 영향은 없지만 메타데이터가 비어 들어간다. +- 키 충돌 방지 없음 — 같은 파일명을 올리면 덮어쓴다. + +호출처: +- `useRefFiles.handleUploadImageRefFile()` — 사용자가 도면 배경으로 일반 이미지(PNG/JPG/JPEG/BMP/GIF) 를 선택했을 때. +- `useRefFiles.handleFileDelete()` — 배경 이미지 제거 시 (저장된 `currentCanvasPlan.bgImageName` 기준). + +### 2.2 `/api/image/cad` — CAD(.dwg/.dxf) 변환 이미지 업로드/삭제 + +파일: `src/app/api/image/cad/route.js` + +- **POST**: 변환된 PNG `file` 을 `cads/{file.name}` 로 업로드. `ContentType` 은 항상 `image/png` 로 명시. +- **DELETE**: `?fileName=` 으로 `cads/{fileName}` 삭제. + +호출처: +- `useRefFiles.handleUploadConvertRefFile()`. 다음 2단계 흐름: + 1. 사용자가 `.dwg`/`.dxf` 선택 → `NEXT_PUBLIC_CONVERTER_DWG_API_URL` / `..._DXF_API_URL` 에 직접 POST → 응답 JSON 의 `Files[0].FileData` 가 Base64 PNG. + 2. Base64 → `Blob` → `File` 로 재포장 → `/api/image/cad` 로 POST → S3 의 `cads/` 에 저장 → CloudFront URL 로 캔버스 배경 세팅. + +### 2.3 `/api/image/map` — 구글 정적지도 캡처 + +파일: `src/app/api/image/map/route.js` + +- **GET**: `?q=&fileNm=&zoom=` 으로 호출. + - `https://maps.googleapis.com/maps/api/staticmap?center={q}&zoom={zoom}&maptype=satellite&size=640x640&scale=1&key=...` 를 서버에서 fetch. + - 응답 바이너리를 `maps/{fileNm}` 로 업로드 (`ContentType: image/png`). +- **DELETE**: `?fileName=` 으로 `maps/{fileName}` 삭제. + +⚠️ 보안 주의: Google Maps API 키가 라우트 파일에 **하드코딩** 되어 있다 (`route.js` 의 `API_KEY` 상수). 키를 환경변수로 옮기고 .env 로 빼는 것이 권장. + +호출처: +- `useRefFiles.handleMapImageDown()` — 도면 배경 설정 모달에서 주소(query) 와 줌 레벨로 위성 이미지를 요청. +- `useRefFiles.handleAddressDelete()` — 지도 배경 제거. + +### 2.4 `/api/image/canvas` — 캔버스 도면 캡처(가장 복잡) + +파일: `src/app/api/image/canvas/route.js` + +원본 캡처를 받아 **2벌(셀범위용 + 풀사이즈용)** 로 가공해 업로드한다. 엑셀 견적/도면 시트에 끼워넣는 이미지 비율이 다르기 때문에 양쪽 다 필요. + +#### 클라이언트 (`src/hooks/floorPlan/useImgLoader.js`) + +`handleCanvasToPng(type)` 흐름: + +1. 그리드/흡착점/마우스라인 등 캡처에 불필요한 객체를 숨김 (`toggleLineEtc(false)`). +2. 캔버스 배경을 `#ffffff` 로 강제 (서버 autocrop 이 흰 여백을 인식하도록). +3. 캔버스 위 이미지 객체들에 `crossOrigin='anonymous'` 를 재적용하고 onload 대기 (CORS 보호용). +4. `requestAnimationFrame` 으로 렌더 완료까지 최대 3초 대기 → 초과 시 `RENDER_TIMEOUT` throw. +5. `ROOF` / `lengthText` / `arrow` 객체들의 `getBoundingRect()` 합집합 + margin 80 으로 크롭 좌표 계산. +6. `canvas.toDataURL({ format:'png', multiplier:2, left/top/width/height })` 로 도면 영역만 2배 해상도로 캡처. +7. base64 → `Blob` → `FormData('file', 'canvas.png')` + `objectNo`, `planNo`, `type`, `width/height/left/top`(left/top 은 항상 0; 이미 크롭됨) 으로 `/api/image/canvas` POST. +8. 성공/실패와 무관하게 finally 동작: 배경 복원 + 숨겼던 객체 복원. + +#### 서버 4단계 처리 + +1. **원본 임시 업로드** — 클라가 보낸 PNG 를 `Drawing/{uuid}` 키로 S3 PUT (uuid 충돌 방지용 임시 키). +2. **가공** — `processImage(Key)`: + - `loadAndCrop(Key)`: S3 GET → Buffer → `Jimp.read` → `autocrop({ tolerance: 0.05, leaveBorder: 5 })`. + - 가공된 Jimp 객체를 `clone()` 으로 2벌 만든 뒤 각각: + - `resizeForFull(image)` — A4 가로 인쇄영역(약 27.7cm × 19.0cm @ 200 DPI = 2181×1496) 의 95% 안에 비율 유지 리사이즈 후, A4 비율 흰 배경에 중앙 배치. + - `resizeForCell(image)` — 엑셀 셀범위(35.4cm × 12.89cm @ 200 DPI = 2787×1015) 의 98% 안에 리사이즈 후, 셀 비율 흰 배경에 중앙 배치. + - 각각 `getBuffer('image/png')` 로 직렬화. +3. **2벌 업로드(병렬)** — `Promise.all` 로: + - `Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}.png` (= 셀범위용, 기존 이미지명) + - `Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}_full.png` (= 풀사이즈용) +4. **원본 임시 삭제** — `Drawing/{uuid}` 를 `DeleteObjectCommand` 로 제거. + +응답 JSON: + +```json +{ + "filePath": "https://{AMPLIFY_BUCKET}.s3.{AWS_REGION}.amazonaws.com/Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}.png", + "fullFilePath": ".../{objectNo}_{planNo}_{type}_full.png", + "fileName": "Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}.png", + "fullFileName": "Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}_full.png" +} +``` + +> `type` 값 의미: `1` = 모듈만 올린 상태, `2` = 가대까지 올린 상태 (`useImgLoader` 의 JSDoc 기준). + +### 2.5 `/api/image/estimate-image-copy` — 견적 이미지 복사 + +파일: `src/app/api/image/estimate-image-copy/route.js` + +플랜을 새 번호로 복제할 때 견적용 캔버스 이미지(`_1.png`, `_2.png`) 를 S3 내부에서 그대로 복사한다. + +흐름: + +1. 입력: `{ objectNo, planNo, newObjectNo, newPlanNo }` (JSON 본문). +2. `_1.png`, `_2.png` 각각 `GetObjectCommand` 로 존재 확인. +3. **둘 다** httpStatusCode 200 인 경우에만 `CopyObjectCommand` 2회 실행: + - `Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{1|2}.png` → + `Drawing/{S3_PROFILE}/{newObjectNo}_{newPlanNo}_{1|2}.png` +4. 둘 중 하나라도 없으면 400 응답 (`'견적서 이미지 복사 실패(존재하지 않는 이미지)'`). + +호출처: `src/hooks/floorPlan/estimate/useEstimateController.js:524`. + +⚠️ 한 가지만 있는 경우(예: `_1.png` 만 존재) 도 모두 실패로 떨어지는 점에 유의. +또한 `_full.png` 변종은 **복사하지 않는다**. 캔버스 재캡처가 일어나지 않는 시나리오라면 새 플랜에서 `_full` 자산이 누락될 수 있다. + +## 3. S3 키 / 폴더 레이아웃 + +``` +{AMPLIFY_BUCKET}/ +├── upload/ # /api/image/upload (참조 이미지) +│ └── {원본파일명} +├── cads/ # /api/image/cad (CAD 변환 후 PNG) +│ └── {변환된파일명} +├── maps/ # /api/image/map (구글 정적지도) +│ └── {planId 등 fileNm} +└── Drawing/ + ├── {uuid} # /api/image/canvas 처리 중 임시 (성공 시 삭제됨) + └── {S3_PROFILE}/ # local / dev / prd1 등 + ├── {objectNo}_{planNo}_1.png # 셀범위용, type=1 + ├── {objectNo}_{planNo}_1_full.png # 풀사이즈용, type=1 + ├── {objectNo}_{planNo}_2.png # 셀범위용, type=2 + └── {objectNo}_{planNo}_2_full.png # 풀사이즈용, type=2 +``` + +## 4. 클라이언트 호출 매핑 + +| 사용자 동작 | 클라이언트 hook/메서드 | 호출 API | +|---|---|---| +| 도면 배경에 일반 이미지 업로드 | `useRefFiles.handleUploadImageRefFile` | POST `/api/image/upload` | +| 도면 배경 이미지 삭제 | `useRefFiles.handleFileDelete` | DELETE `/api/image/upload` | +| DWG/DXF 업로드 | `useRefFiles.handleUploadConvertRefFile` | (외부 converter) → POST `/api/image/cad` | +| 주소로 위성지도 가져오기 | `useRefFiles.handleMapImageDown` | GET `/api/image/map` | +| 위성지도 배경 삭제 | `useRefFiles.handleAddressDelete` | DELETE `/api/image/map` | +| 견적용 도면 캡처 | `useImgLoader.handleCanvasToPng(type)` | POST `/api/image/canvas` | +| 견적 이미지 복사(플랜 복제) | `useEstimateController` (524행) | POST `/api/image/estimate-image-copy` | + +위 5개 라우트 이외의 경로에서는 `@aws-sdk/client-s3` 를 사용하지 않는다. + +## 5. 캔버스 캡처에서 주의해야 할 가드 + +`canvas` 라우트와 `useImgLoader` 의 호흡이 깨지면 빈 이미지/잘린 이미지/타임아웃이 쉽게 발생한다. 변경 시 반드시 유지할 것: + +- **흰 배경 강제** — 격자/도트 그리드가 남아 있으면 서버 `autocrop` 이 도면 영역을 인식 못 한다. +- **multiplier=2** — 클라가 보내는 픽셀 해상도. 셀범위/풀사이즈 양쪽이 200 DPI 기준 픽셀 산출이라 클라 multiplier 와 서버 DPI 가 어긋나면 시트에서 흐리게 보이거나 잘려나간다. +- **렌더 대기** — `requestAnimationFrame` 후 캡처. 동기 캡처는 fabric 의 객체 가시성 변경이 반영되기 전에 dataURL 을 만들 수 있다. +- **CORS 재로드** — 캔버스에 외부 이미지(예: S3 배경) 가 떠 있으면 `crossOrigin='anonymous'` 로 다시 로드하지 않는 한 `toDataURL` 이 tainted canvas 에러를 던진다. +- **finally 정리** — 배경/숨겼던 객체를 catch 분기에서도 복원해야 사용자 화면이 깨지지 않는다. + +## 6. 알려진 거친 모서리(Backlog 후보) + +- `image/map` 의 Google API 키 하드코딩 — 환경변수 이전 필요. +- `image/upload` POST 의 `ContentType: file.ContentType` 은 항상 `undefined` (속성명이 `type` 임). 메타데이터 정확도가 필요해지면 `file.type` 으로 교정. +- `image/upload`/`image/cad` 의 키 충돌 없음 — 동일 파일명 업로드 시 무음으로 덮어쓰기. +- `estimate-image-copy` 가 `_full` 변종을 복사하지 않음. 새 플랜에서 P1/P2 시트 출력이 누락될 가능성. +- `estimate-image-copy` 가 둘 중 하나만 있을 때도 전체 실패로 처리.