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

12 KiB
Raw Permalink Blame History

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-s3S3Client 를 모듈 로드 시점에 한 번 생성한다. 인증/리전/버킷은 환경변수로만 주입한다.

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.jssetCurrentBgImage 호출들.

2. 라우트별 흐름

2.1 /api/image/upload — 참조 이미지 업로드/삭제

파일: src/app/api/image/upload/route.js

  • POST: multipart/form-datafile 한 개를 받아 upload/{file.name}PutObjectCommand. 응답: { filePath, fileName, message }.
  • DELETE: ?fileName=... 쿼리로 upload/{fileName} 키를 DeleteObjectCommand.

특이사항:

  • ContentTypefile.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 filecads/{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 → BlobFile 로 재포장 → /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.jsAPI_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 → BlobFormData('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.readautocrop({ 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:

{
  "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}.pngDrawing/{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 가 둘 중 하나만 있을 때도 전체 실패로 처리.