Post Detail Page
Cloudflare - one time upload URL 사용해서 이미지 업로드하기
Cloudflare Images
사용자 대시보드를 생성할 때 서비스에서 제공되는 기본 이미지와 사용자가 선택한 이미지의 URL을 formData에 넣어 보드 생성 API로 POST해야 하는 작업 방식을 고민하던 중, API가 노출되지 않으면서 일회성 URL을 사용해 이미지를 업로드 할 수 있는 Cloudflare Images를 사용하게 되었다. 프로젝트 이미지 최적화를 위해 Cloudflare CDN을 사용 중이었기 때문에, 접근성이 좋아 선택하는 데 큰 요소로 작용했다.
Cloudflare Images 사용 이유
-
API 보안 유지
API 키나 토큰을 클라이언트에 노출하지 않고도 일회성 업로드 URL을 사용하여 이미지를 업로드할 수 있다. -
성능 향상
사용자의 가까운 서버에서 이미지를 제공함으로써 로딩 시간을 크게 단축시킨다. -
비용 효율성
비용이 저렴하며, 자체 이미지 저장소와 전송 인프라를 운영하는 것보다 훨씬 경제적이다.
Accept direct creator uploads
🔗 Cloudflare Upload Images
📌 Request a one-time upload URL
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2/direct_upload \
--header "Authorization: Bearer <API_TOKEN>" \
--form 'requireSignedURLs=true' \
--form 'metadata={"key":"value"}'
- account_id: Cloudflare 계정의 ID
- API_TOKEN: Cloudflare API 토큰
account_id
와api_token
을 direct_upload 엔드포인트로 POST 요청을 보낸 후, 요청이 성공하면 다음과 같은JSON
이 반환된다.
{
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"uploadURL": "https://upload.imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901"
},
"result_info": null,
"success": true,
"errors": [],
"messages": []
}
uploadURL
: 이미지를 업로드할 수 있는 URLid
: 업로드된 이미지의 고유 ID
getUploadUrl
export const getUploadUrl = async () => {
const response = await (
await fetch(`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ID}/images/v2/direct_upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${CLOUDFLARE_TOKEN}`,
},
})
).json();
return response;
};
💬 URL은 언제 받는게 좋을까?
💡 Direct Creator Upload를 사용하면 중간 저장소 버킷을 사용하지 않아 이에 따른 저장/송신 비용이 발생하지 않는다.
URL 요청 당 추가적인 비용이 발생하지 않음
최종 폼 제출 시점에 URL을 요청하고 이미지를 업로드하는 과정을 한꺼번에 처리하면 시간이 오래 걸려 사용자 경험이 저하될 가능성이 커 보였다. 위 내용을 고려했을 때 사용자가 이미지를 선택할 경우 업로드 URL만 요청하고 실제 이미지 업로드는 폼 제출이 확정됐을 때 진행한다면 최종 폼 제출 시 지연 시간을 최소화하여 사용자 경험도 개선될 수 있다고 생각되어 아래와 같이 진행하기로 했다.
1. 이미지 선택 시 미리보기 및 URL 요청 준비
• 사용자가 이미지를 선택하면 이미지를 로컬에서 미리보기로 보여주고, 업로드 URL을 미리 요청한다.
• 실제 업로드는 사용자가 폼을 제출할 때 진행한다.
2. 폼 제출 시 업로드 및 보드 생성
• 폼 제출 시 URL을 사용하여 이미지를 업로드하고, 업로드가 완료된 후에 폼 데이터를 업데이트하여 최종 보드 생성 요청을 보낸다.
📌 사용자 이미지 등록 시 uploadURL 요청
- UX 개선: 이미지 업로드 전 미리보기 확인
- one-time URL 요청
onImageChange
// 사용자가 이미지 등록할 때마다 URL 받기
const onImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { files },
} = event;
if (!files) return;
const file = files[0];
// 2.이미지 미리보기 URL 생성
const imagePreviewUrl = URL.createObjectURL(file);
// 3.이전에 등록된 미리보기 URL 삭제
if (previewImage) {
URL.revokeObjectURL(previewImage);
}
// 4.상태 업데이트
setPreviewImage(imagePreviewUrl); // 이미지 미리보기 URL 저장
setFile(file); // 사용자 등록 파일 저장
// 5.one-time Upload URL 요청
const { success, result } = await getUploadUrl();
if (success) {
const { id } = result;
setUploadId(id);
} else {
console.error('Failed to get upload URL:', result);
}
};
- 파일 선택
사용자가 파일을 선택하면 이벤트 발생 - 미리보기 URL 생성
URL.createObjectURL
를 사용하여 선택한 파일의 미리보기 URL 생성 - 이전 미리보기 URL 삭제
URL.revokeObjectURL
를 사용하여 이전 미리보기 URL을 메모리에서 해제 - 상태 업데이트
생성한미리보기 URL
과선택한 파일
을 상태에 저장 - One-time Upload URL 요청
getUploadUrl
함수를 호출하여 Cloudflare에서업로드 URL
을 요청하고, 성공 시 업로드ID
저장
📌 Cloudflare로 사용자 이미지 업로드하기
uploadImageCloudflare
export const uploadImageCloudflare = async (file: File, uploadId: string) => {
// 1.파일 업로드 URL 생성
const cloudflareImageUrl = `https://upload.imagedelivery.net/${CLOUDFLARE_ACCOUNT_HASH}/${uploadId}`;
// 2.FormData 객체 생성
const cloudflareForm = new FormData();
cloudflareForm.append('file', file);
// 3.이미지 업로드 요청
const response = await fetch(cloudflareImageUrl, {
method: 'POST',
body: cloudflareForm,
});
if (!response.ok) {
alert('Cloudflare: 이미지 업로드 실패했습니다.');
}
return response;
};
- 파일 업로드 URL 생성
ACCOUNT_HASH와 uploadId를 사용하여 Cloudflare 업로드 URL을 생성 - FormData 객체 생성
업로드할 파일을 포함하는 FormData 객체 생성 - 이미지 업로드 요청
📌 Check the image record status
Cloudflare에 이미지가 정상적으로 업로드 되었는지 확인
성공적으로 업로드 되었다면 해당 파일을 Submit 함수의 FormData의 backgroundImageURL을 업데이트한다.
curl https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id} \
--header "Authorization: Bearer <API_TOKEN>"
account_id
: Cloudflare 계정 IDimage_id
: 업로드된 이미지의 IDAPI_TOKEN
: Cloudflare API 토큰
요청 성공 시 아래와 같은 JSON 응답을 받는다.
{
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"metadata": {
"key": "value"
},
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": true,
"variants": [
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901/public",
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901/thumbnail"
],
"draft": true
},
"success": true,
"errors": [],
"messages": []
}
checkUploadStatus
// 업로드된 이미지 등록 상태 확인
export const checkUploadStatus = async (uploadId: string) => {
const response = await (
await fetch(`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ID}/images/v1/${uploadId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${CLOUDFLARE_TOKEN}`,
},
})
).json();
return response;
};
📌 최종 업로드 상태 확인 및 제출
const onSubmit = async () => {
// 생략...
if (file) {
// 1.Cloudflare로 사용자 이미지 업로드하기
const uploadResponse = await uploadImageCloudflare(file, uploadId);
if (!uploadResponse.ok) return;
// 2.업로드 요청 성공 여부 확인하기
const checkResponse = await checkUploadStatus(uploadId);
const imageUrl = checkResponse.result.variants[0]; // 업로드된 이미지 URL
// 3.Form 업데이트
updatedFormData.backgroundImageURL = imageUrl;
}
postFormMutation(updatedFormData);
};
💬 이미지 업로드 시나리오
Image API를 사용하여 일회성 업로드 URL을 받아오는 것은 비교적 간단하지만 어느 시점에서 API를 호출하고 받아온 URL을 formData로 저장할지에 대한 고민 없이 진행하다 보니, 데이터를 초기화했음에도 불구하고 URL이 서버에 저장되는 등의 문제가 생겨 먼저 시나리오를 작성한 후 이를 기반으로 다시 적용해 보기로 했다.
1️⃣ 필수 조건 및 제약 사항 (변경 불가)
-
API 요구사항
name
: 필수 조건
backgroundColor
: 필수 조건
backgroundImageUrl
: 선택 조건 -
제약 사항
backgroundImage가 선택되더라도backgroundColor
는필수 값
이므로 반드시 데이터가 필요합니다.
backgroundColor가 선택되면backgroundImageUrl
값은null
이어야 합니다.
3️⃣ 이미지 업로드
- 사용자가 파일을 선택한다.
- 선택된 파일이 있다면 one-time upload URL을 요청한다.
- 요청한 URL로 이미지를 업로드한다.
- 업로드 성공 시 반환된 URL을 저장한다.
- formData를 업데이트한다.
4️⃣ 다양한 이미지 업로드 시나리오
처음부터 이미지 업로드를 클릭해서 바로 생성하면 좋겠지만, 사용자의 선택에 따른 파일 데이터 초기화 작업과 백엔드 제약 사항을 포함한 다양한 과정을 고려해야 한다. 실제 유저들은 상상 이상의 다양한 행동을 하는 경우가 굉장히 많다.. 모든 상황을 예측할 수 없어 기본적인 상황을 중심으로 시나리오를 작성했다.
- Color 탭의 첫번째 색상이 선택되어 있다
backgroundColor: 'blue'
backgroundImageURL: null - 다른 색상을 한 번씩 눌러본다
backgroundColor: '색상 값'
backgroundImageURL: null - Image 탭을 클릭한다
- 기본 배경 이미지를 하나씩 눌러본다
backgroundColor: '마지막 클릭한 색상 값'
backgroundImageUrl: '기본 배경 이미지 URL' - file 추가하기 버튼을 클릭한다
- 원하는 이미지 파일을 선택한다
이미지 파일 확장자 확인
이미지 파일 아닌 경우 오류 UI 제공
이미지 크기 3MB 이하인지 확인
이미지 파일 크기 제한 초과 시 오류 UI 제공
이미지 미리보기 URL 생성 및 미리보기 UI 제공
one-time upload URL
요청
backgroundImageUrl: 'upload url' - 다시 기본 배경 이미지를 클릭한다
file 정보 초기화
backgroundImageURL
: '기본 배경 이미지 URL' - Color 탭을 클릭한다
- 다른 색상을 한 번씩 눌러본다
file 정보 초기화
backgroundImageURL: null
- file 추가하기 버튼을 클릭한다
- 원하는 이미지 파일을 선택한다
6번 과정 동일 - create Board 버튼을 누른다
선택한 파일이 있는지 확인
upload URL로 이미지 업로드
이미지가 성공적으로 업로드된 경우 반환된 이미지 URL 저장(실제 등록된 이미지 URL)
formData 업데이트
5️⃣ 사용자가 파일을 등록하지 않은 경우
기본으로 제공되는 첫 번째 컬러 옵션이 선택되어 있어 backgroundColor: 'blue'로 생성된다.
📌 초기화 코드 추가
BackgroundImageOptions
const BackgroundImageOptions = ({ ... }) => {
const onImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
// ...
}
const handleDefaultImageClick = (id: string, value: string, type: string) => {
setPreviewImage(value);
// 기본 이미지 선택 시 사용자 등록 이미지 file 정보 초기화
setFile(null);
setUploadId('');
onClick(id, value, type);
};
return (
// ...
)
}
export default BackgroundImageOptions;
BackgroundColorOptions
const BackgroundColorOptions = ({ colorList, selectedImage, onClick, setFile }: BackgroundColorOptionsProps) => {
const handleColorOptionClick = (id: string, value: string, type: string) => {
// 기본 색상 선택 시 사용자 등록 이미지 file 정보 초기화
setFile(null);
onClick(id, value, type);
};
return(
// ...
)
}
마무리하며
기본 색상 클릭 시 사용자 등록 파일이 함께 저장되는 문제는 초기화 단계가 부분적으로 적용되어 있어 발생한 문제임을 시나리오 작성 후 알게 되었고 실제 로직에 문제가 발생했을 때 해결하는 데 큰 도움이 되었다.
앞으로 개선할 부분은 종합적인 에러 처리다. 네트워크 오류, 파일 크기 초과, 지원되지 않는 파일 형식 등의 다양한 에러 상황에 대한 처리를 추가하고 업로드 중에는 네트워크 요청을 중복해서 보내지 않도록 대기(Pending) 상태 처리 및 명확한 오류 메시지를 제공하는 UI를 개선할 예정이다.