5개 이미지 API 라우트(upload/cad/map/canvas/estimate-image-copy)의 서버 처리, 클라이언트 콜러(useRefFiles/useImgLoader/useEstimateController), S3 키 레이아웃, 캔버스 캡처 가드 조건과 알려진 거친 모서리를 한 문서에 정리. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 KiB
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으로 넣지만 WebFile/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단계 흐름:- 사용자가
.dwg/.dxf선택 →NEXT_PUBLIC_CONVERTER_DWG_API_URL/..._DXF_API_URL에 직접 POST → 응답 JSON 의Files[0].FileData가 Base64 PNG. - 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) 흐름:
- 그리드/흡착점/마우스라인 등 캡처에 불필요한 객체를 숨김 (
toggleLineEtc(false)). - 캔버스 배경을
#ffffff로 강제 (서버 autocrop 이 흰 여백을 인식하도록). - 캔버스 위 이미지 객체들에
crossOrigin='anonymous'를 재적용하고 onload 대기 (CORS 보호용). requestAnimationFrame으로 렌더 완료까지 최대 3초 대기 → 초과 시RENDER_TIMEOUTthrow.ROOF/lengthText/arrow객체들의getBoundingRect()합집합 + margin 80 으로 크롭 좌표 계산.canvas.toDataURL({ format:'png', multiplier:2, left/top/width/height })로 도면 영역만 2배 해상도로 캡처.- base64 →
Blob→FormData('file', 'canvas.png')+objectNo,planNo,type,width/height/left/top(left/top 은 항상 0; 이미 크롭됨) 으로/api/image/canvasPOST. - 성공/실패와 무관하게 finally 동작: 배경 복원 + 숨겼던 객체 복원.
서버 4단계 처리
- 원본 임시 업로드 — 클라가 보낸 PNG 를
Drawing/{uuid}키로 S3 PUT (uuid 충돌 방지용 임시 키). - 가공 —
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')로 직렬화.
- 2벌 업로드(병렬) —
Promise.all로:Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}.png(= 셀범위용, 기존 이미지명)Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{type}_full.png(= 풀사이즈용)
- 원본 임시 삭제 —
Drawing/{uuid}를DeleteObjectCommand로 제거.
응답 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 내부에서 그대로 복사한다.
흐름:
- 입력:
{ objectNo, planNo, newObjectNo, newPlanNo }(JSON 본문). _1.png,_2.png각각GetObjectCommand로 존재 확인.- 둘 다 httpStatusCode 200 인 경우에만
CopyObjectCommand2회 실행:Drawing/{S3_PROFILE}/{objectNo}_{planNo}_{1|2}.png→Drawing/{S3_PROFILE}/{newObjectNo}_{newPlanNo}_{1|2}.png
- 둘 중 하나라도 없으면 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/uploadPOST 의ContentType: file.ContentType은 항상undefined(속성명이type임). 메타데이터 정확도가 필요해지면file.type으로 교정.image/upload/image/cad의 키 충돌 없음 — 동일 파일명 업로드 시 무음으로 덮어쓰기.estimate-image-copy가_full변종을 복사하지 않음. 새 플랜에서 P1/P2 시트 출력이 누락될 가능성.estimate-image-copy가 둘 중 하나만 있을 때도 전체 실패로 처리.