Compare commits
2 Commits
0211c57103
...
589067d4b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 589067d4b2 | |||
|
|
313c3c629d |
197
docs/S3 이미지 저장 흐름.md
Normal file
197
docs/S3 이미지 저장 흐름.md
Normal file
@ -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` 가 둘 중 하나만 있을 때도 전체 실패로 처리.
|
||||
Loading…
x
Reference in New Issue
Block a user