qcast-front/docs/S3 이미지 저장 흐름.md
sangwook yoo 313c3c629d docs(S3): S3 이미지 저장 흐름 정리 문서 추가
5개 이미지 API 라우트(upload/cad/map/canvas/estimate-image-copy)의 서버 처리,
클라이언트 콜러(useRefFiles/useImgLoader/useEstimateController), S3 키 레이아웃,
캔버스 캡처 가드 조건과 알려진 거친 모서리를 한 문서에 정리.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 15:58:53 +09:00

198 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 가 둘 중 하나만 있을 때도 전체 실패로 처리.