Logo

Prolip's Blog

에디터에 이미지 업로드 구현하기
  • #Editor
  • #Project
  • #ModuReview

에디터에 이미지 업로드 구현하기

Prolip

2025-07-11

titap으로 만든 에디터에 이미지 업로드 기능 구현하기, presignd url 발급하고 S3에 직접 업로드하기

시작하며

이전 게시글에서 에디터는 잘 구현했는데 이미지도 올릴 수 있어야 합니다. 먼저 우리 프로젝트의 저장 흐름은 다음과 같았는데요.

  1. 사용자가 이미지 업로드를 시도.
  2. 사용자가 업로드하려는 이미지 타입을 요청 본문에 담아 백엔드 서버로 보냄.
  3. 백엔드 서버는 S3 스토리지로 업로드하기 위한 presigned url과 이미지 uuid를 응답 본문에 담아 응답.
  4. 응답 받은 presigned url 주소로 이미지를 업로드.
  5. 업로드가 끝나면 cloudfront 주소와 uuid 주소를 조합해 이미지 태그의 src 속성에 주입해 에디터 내에 삽입.

이런 흐름으로 이루어지고 있습니다.

Presigned URL을 사용한 이유

S3 스토리지에 직접 접근할 수 있게 열어두면 여러 문제가 발생하는데요. 누구나 스토리지 주소를 알고 있기 때문에 파일을 업로드할 수 있게 돼요. 그럼 누군가 우리 프로젝트 스토리지를 개인 스토리지처럼 이용하거나 정상적으로 업로드된 파일을 악성 파일로 바꿀 수도 있습니다.

이런 문제를 해결하고자 presigned url을 발급하는 방식을 채택하게 됐습니다. 이 방식은 요청 본문에 사용자가 올리고자 하는 이미지 파일의 타입을 보내면, 서버는 해당 타입을 검증하고 지정된 시간 동안만 유효한 임시 url을 발급해 클라이언트로 보내줍니다.

이 임시 url을 통해서만 S3 스토리지에 파일을 업로드할 수 있기 때문에 위 방식보단 비교적 안전하다고 볼 수 있습니다.

CloudFront를 통한 서빙

파일 업로드가 끝나면 사용자는 S3 스토리지에 직접 접근하지 않습니다.

cloudfront가 S3와 클라이언트 사이의 캐시 서버 역할을 하기 때문에 S3 원본 주소가 노출되지 않습니다. 제가 이전에 작성한 글에 나와있듯, 퍼블릭 액세스를 차단하고 cloudfront만이 접근해 서빙이 가능한 구조를 만들 수 있습니다.

image-upload-node

사실 tiptap은 생태계가 아주 잘 형성되어있다고 생각합니다. 위의 동작과 연결하기 좋은 image-upload-node라는 익스텐션을 이미 누군가 만들어놓았는데요.

const editor = useEditor({ extensions: [ StarterKit, Image, ImageUploadNode.configure({ accept: 'image/*', maxSize: MAX_FILE_SIZE, limit: 3, upload: handleImageUpload, onError: (error) => console.error('Upload failed:', error), }), ], })

Image upload node | Tiptap UI Component 이 라이브러리를 설치해 연결만해주면 딱이다!! 싶었어요. 이렇게 upload 속성에 임시 url 발급과 파일 업로드를 담당하는 핸들러 함수를 연결하면 끝이겠구나 싶었거든요.

하지만 현재 코드 베이스와 맞지 않는 부분이 많아 결국 깃허브를 찾아 코드를 조금 변형해서 적용했습니다. 굳이 맞지 않는 부분을 설명해드리자면

  1. 우리 프로젝트는 다중 파일 업로드를 지원하지 않으나, 해당 패키지는 다중 파일 업로드 코드가 포함되어있다. (와중에 다중 파일 업로드 관련 코드가 꽤 길어 불필요한 코드가 많습니다.)
  2. 해당 패키지의 업로드 박스 UI는 모두 영문자로 제공되어있으나 UI 수정이 불가능하다.

이런 이유로 이 패키지 코드를 참고해 필요한 부분만 가져오고, 적당히 고쳐서 적용했는데요. 위의 문제가 문제가 되지 않는다면 굳이 코드를 변경할 일 없이 설치 후 그대로 쓰셔도 무방합니다.

ImageUploadNode.tsx

image

이제 실제로 위와 같이 에디터 내에 표시될 이미지 업로드 노드 컴포넌트를 구현해야 하는데요. 먼저 에디터와 동기화할 수 있게 우리만의 확장 옵션을 만들어줘야 합니다.

import {mergeAttributes, Node, ReactNodeViewRenderer} from '@tiptap/react'; import ImageUploadNodeComponent from './ui/ImageUploadNodeComponent'; type ImageUploadNodeOptions = { accept?: string; maxSize?: number; upload?: UploadFunction; onError?: (error: ClientError | RequestError) => void; }; declare module '@tiptap/react' { interface Commands<ReturnType> { imageUpload: { setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType; }; } } const ImageUploadNode = Node.create<ImageUploadNodeOptions>({ name: 'imageUpload', group: 'block', draggable: true, selectable: true, atom: true, addOptions() { return { accept: 'image/*', maxSize: 0, upload: undefined, onError: undefined, }; }, addAttributes() { return { accept: { default: this.options.accept, }, maxSize: { default: this.options.maxSize, }, }; }, parseHTML() { return [ { tag: 'div[data-type="image-upload"]', }, ]; }, renderHTML({HTMLAttributes}) { return ['div', mergeAttributes({'data-type': 'image-upload'}, HTMLAttributes)]; }, addNodeView() { return ReactNodeViewRenderer(ImageUploadNodeComponent); }, addCommands() { return { setImageUploadNode: (options = {}) => ({commands}) => { return commands.insertContent({ type: this.name, attrs: options, }); }, }; }, }); export default ImageUploadNode;

굳이 리액트 컴포넌트를 에디터 위에 붙이지 않고 tiptap에서 제공하는 Node.create 함수를 사용해 익스텐션으로 만든 이유는 다음과 같습니다.

1. 문서 구조와 동기화하기 위해

tiptap은 에디터 내부 상태를 ProseMirror의 문서 모델로 관리하는데요. 노드로 정의하면 이미지 업로드 영역 자체가 문서의 한 부분으로 동작하기 때문에 선택, 삭제, 드래그 등 기본적인 데이터 기능과 호환됩니다.

단순히 리액트 컴포넌트를 삽입하게 되면 업로드 중인 이미지를 통째로 드래그한다거나 백스페이스를 통해 삭제하는 동작을 모두 직접 구현해줘야만 합니다.

2. 복잡한 UI 구현을 위해

image-upload.gif

tiptap의 ReactNodeViewRenderer를 이용하면 노드를 리액트 컴포넌트로 렌더링할 수 있는데요. 업로드 진행률, 취소 버튼, 에러 상태 UI 등 복잡한 상호작용을 쉽게 구현할 수 있습니다. 이 때 에디터는 이 영역 자체를 문서의 한 덩어리로 다루기 때문에 위 문서 구조와 동기화에서 여전히 유효하게 동작합니다.

코드만 띡 기록해놓으면 나중에 까먹으니까 이제 코드의 흐름을 따라 기록해보겠습니다.

노드 스펙 정의

type ImageUploadNodeOptions = { accept?: string; maxSize?: number; upload?: UploadFunction; onError?: (error: ClientError | RequestError) => void; }; declare module '@tiptap/react' { interface Commands<ReturnType> { imageUpload: { setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType; }; } } const ImageUploadNode = Node.create<ImageUploadNodeOptions>({ name: 'imageUpload', group: 'block', draggable: true, selectable: true, atom: true, // ... });

먼저 이미지 업로드 노드의 기본적인 설정이 필요한데요.

  • name: ProseMirror 문서 모델에서 이 영역을 가리키는 키로 동작합니다. 역할을 잘 나타내는 ‘imageUpload’로 설정했습니다.
  • group: 문단 수준의 블록으로 동작하도록 ‘block’으로 설정했습니다. ‘inline’ 설정도 가능합니다.
  • draggable, selectable: 사용자가 이 덩어리를 선택하거나 드래그할 수 있도록 true로 설정했습니다.
  • atom: 에디터가 이 영역을 쪼갤 수 없는 원자 단위로 보도록 설정합니다. 이 영역 내부에 로딩바, 버튼이 있어도 문서 모델이 이 영역을 한 덩어리로 보게 됩니다. (이렇게 설정해줘야 삭제나 이동할 때 예측이 가능해집니다.)

옵션과 속성 설정

addOptions() { return { accept: 'image/*', maxSize: 0, upload: undefined, onError: undefined, }; }, /** 사용할 때 이렇게 옵션을 설정할 수 있음!! const editor = useEditor({ extensions: [ StarterKit, ImageUploadNode.configure({ accept: 'image/*', maxSize: 5 * 1024 * 1024, upload: handleImageUpload, onError: updateError, }), ] }); **/ addAttributes() { return { accept: { default: this.options.accept, }, maxSize: { default: this.options.maxSize, }, }; },
  • addOptions: useEditor를 사용해 에디터 객체를 초기화할 때 해당 익스텐션에 전달할 수 있는 옵션을 정의합니다. addOptions 메서드 내부에 정의된 값들은 기본값으로 사용됩니다.
    • accept: 허용할 이미지 타입으로 기본값은 모든 이미지 타입을 허용합니다.

    • maxSize: 허용할 이미지 최대 크기를 설정합니다.

    • upload: 이미지가 선택됐을 때 실행될 업로드 함수인데요. 아래 타입에 맞는 함수를 등록할 수 있어요.

      export type UploadFunction = ( file: File, onProgress: (event: {progress: number}) => void, abortSignal: AbortSignal, ) => Promise<string>;
    • onError: 이미지 업로드 중 발생한 에러를 핸들링할 수 있는 콜백 함수를 등록할 수 있습니다.

  • addAttributes: 문서 모델을 문서화, 직렬화할 때 노드에 accpet, maxSize 속성을 함께 저장할 수 있습니다. 그냥 쉽게 이미지 업로드 영역 내부에서 허용 이미지 타입과 최대 크기를 공유할 수 있습니다 마치 context 처럼요.
    • node.attrs 객체로 접근이 가능해지는데, node.attrs.accept, node.attrs.maxSize를 통해 이 값을 조회할 수 있게 됩니다.
    • accept, maxSize: 속성 값으로 옵션에 전달된 값을 사용합니다.

HTML과 에디터 문서 모델 변환

parseHTML() { return [ { tag: 'div[data-type="image-upload"]', }, ]; }, renderHTML({HTMLAttributes}) { return ['div', mergeAttributes({'data-type': 'image-upload'}, HTMLAttributes)]; },
  • renderHTML: 이미지 노드를 HTML 태그로 직렬화하면 <div data-type="image-upload"></div>로 만들어줍니다. 인자의 HTMLAttributes에는 위 addAttributes에서 정의했던 accept, maxSize가 들어오는데요. 이 속성들도 합쳐집니다.
  • parseHTML: HTML을 다시 에디터의 문서 모델로 불러올 때, 다시 이미지 노드로 복원해줍니다.

에디터 내에 리액트 컴포넌트 렌더링하기

여기까지 이미지 업로드 익스텐션에 대한 기본적인 설정이었다고 생각하면 되는데요. 이제 에디터 내에서 사용자와 상호작용하며 이미지를 입력 받고, 업로드 바를 표시하며 업로드 상태에 따라 동적으로 UI를 바꿔주는 리액트 컴포넌트를 에디터 내에 삽입할 수 있어야 합니다.

addNodeView() { return ReactNodeViewRenderer(ImageUploadNodeComponent); },

이미지 업로드 노드가 커맨드를 통해 에디터에 삽입되면 ReactNodeViewRenderer에 등록된 ImageUploadNodeComponent가 표시됩니다.

아직 컴포넌트 코드를 보여드리진 않았지만 등록된 ImageUploadNodeComponent에서 실제로 이미지 드래그&드롭, 프리뷰, 이미지 업로드 등의 동작을 수행하게 됩니다.

커맨드 추가하기

addCommands() { return { setImageUploadNode: (options = {}) => ({commands}) => { return commands.insertContent({ type: this.name, attrs: options, }); }, }; },

이렇게 커맨드를 설정해주면 이후에 에디터 인스턴스에서 editor.commands.setImageUploadNode를 통해 ImageUploadNodeComponent를 에디터 내에 삽입하게 됩니다.

useFileUpload.ts

컴포넌트 구현 전에 업로드 기능을 담당하는 훅을 먼저 구현했는데요. 기존에 만들어진 image-upload-node 패키지 코드를 뜯어서 이미지 업로드 훅을 도둑질하기 시작했습니다.

기존 코드엔 여러 파일을 업로드하기 위한 코드가 섞여있어 분리했으며 우리 프로젝트의 에러 핸들링 방식에 맞게 수정했습니다.

// type.ts export type FileItem = { file: File; progress: number; status: 'uploading' | 'success' | 'error'; abortController: AbortController; }; export type UploadOptions = { maxSize: number; accept: string; upload: (file: File, onProgress: (event: {progress: number}) => void, abortSignal: AbortSignal) => Promise<string>; onError: (error: ClientError) => void; }; // useFileUpload.ts import {useState} from 'react'; import {FileItem, UploadOptions} from '../model/type'; import {ClientError, createClientError} from '@/shared/lib/utils/client-error'; import {RequestError} from '@/shared/apis/request-error'; export default function useFileUpload(options: UploadOptions) { const [fileItem, setFileItem] = useState<FileItem | null>(null); const uploadFile = async (file: File): Promise<string | null> => { if (file.size > options.maxSize) throw createClientError('MAX_SIZE_EXCEEDED'); const abortController = new AbortController(); const newFileItem: FileItem = { file, progress: 0, status: 'uploading', abortController, }; setFileItem(newFileItem); try { const url = await options.upload( file, (event: {progress: number}) => { setFileItem(prev => { if (!prev) return null; return { ...prev, progress: event.progress, }; }); }, abortController.signal, ); if (!url) throw createClientError('UPLOAD_FAILED'); setFileItem(prev => { if (!prev) return null; return { ...prev, status: 'success', url, progress: 100, }; }); return url; } catch (error) { if (abortController.signal.aborted) { return null; } setFileItem(prev => { if (!prev) return null; return { ...prev, status: 'error', progress: 0, }; }); if (error instanceof ClientError || error instanceof RequestError) { options.onError?.(error); } else { options.onError?.(createClientError('UPLOAD_FAILED')); } return null; } }; const clearFileItem = () => { if (!fileItem) return; fileItem.abortController.abort(); setFileItem(null); }; return { fileItem, uploadFile, clearFileItem, }; }

이후에 설명 드리겠지만 업로드 바, UI를 동적으로 변경하기 위해 파일을 상태로 가지고 있어야 합니다. 일단 코드에 대해 기록해보자면

상태 관리

먼저 useState를 통해 fileItem이라는 상태 변수를 관리하는데요. 이 객체 안에 파일 업로드에 필요한 정보들이 담긴다고 생각하면 돼요.

  • file: 사용자가 선택한 실제 파일 객체입니다.
  • progress: 0부터 100까지의 숫자로 업로드 진행률을 나타내요. xhr 객체를 통해 전달할 겁니다.
  • status: 업로드 상태를 나타내는데, 이 상태를 이용해서 UI를 다르게 표시합니다.
  • abortController: AbortController 인스턴스를 사용하면 HTTP 요청을 중간에 취소할 수 있는데요. fetch의 경우 두 번째 인자에 만들어진 인스턴스의 signal 속성을 제공해 인스턴스의 참조를 전달할 수 있습니다. 이후 인스턴스의 abort() 메서드를 호출해 signal 속성에 제공된 요청을 취소할 수 있습니다.

uploadFile

사용자가 파일을 선택하면 호출되는 함수로 업로드 프로세스를 시작하고 관리하는 함수입니다.

if (file.size > options.maxSize) { options.onError?.(createClientError('MAX_SIZE_EXCEEDED')); return; }
  • 파일 크기 검증: 업로드 시작 전 파일 크기가 옵션으로 설정한 최대 크기를 초과하는지 판단하는데요. 만약 초과하면 onError 콜백을 호출해 에러 상태를 업데이트하고 함수를 즉시 종료해요.
const abortController = new AbortController(); const newFileItem: FileItem = { file, progress: 0, status: 'uploading', abortController, }; setFileItem(newFileItem);
  • 업로드 상태 초기화: 업로드 시작을 위해 상태를 uploading으로 설정하고 진행률도 0으로 설정합니다. 컨트롤러의 signal을 업로드 함수에 전달해 사용자가 취소하고 싶다면 중단할 수 있게 abortController를 새로 생성합니다.
const url = await options.upload( file, (event: {progress: number}) => { setFileItem(prev => { if (!prev) return null; return { ...prev, progress: event.progress, }; }); }, abortController.signal, );
  • 파일 업로드: 에디터 초기화 시점에 전달된 upload 함수를 호출해 이미지 업로드를 시작하는데요. upload 함수에는 총 3개의 인자가 필요합니다.
    • file: 업로드할 이미지 파일입니다.
    • onProgress: 업로드 진행률이 바뀔 때마다 호출되는 콜백 함수로 함수 안에서 setFileItem을 계속 호출해 fileItem의 progress 값을 업데이트해요. 덕분에 프로그래스 바를 동적으로 계속 업데이트합니다.
    • abortController.signal: fileItem 초기화 시점에 생성한 abortController 인스턴스의 signal을 전달합니다. 이후에 abortController.abort()를 호출해 업로드 취소를 감지할 수 있어요.
setFileItem(prev => { if (!prev) return null; return { ...prev, status: 'success', url, progress: 100, }; });
  • 업로드에 성공한 경우: 요청에 성공한 경우 이미지 URL이 반환되는데요. fileItem의 상태를 success로 업데이트합니다.
if (!url) throw createClientError('UPLOAD_FAILED');
  • 업로드에 실패한 경우: 만약 url이 전달되지 않은 경우 에러를 throw하며 함수를 중단하게 됩니다.
catch (error) { if (abortController.signal.aborted) { return null; } setFileItem(prev => { if (!prev) return null; return { ...prev, status: 'error', progress: 0, }; }); if (error instanceof ClientError || error instanceof RequestError) { options.onError?.(error); } else { options.onError?.(createClientError('UPLOAD_FAILED')); } return null; }
  • 에러 핸들링:
    • 사용자가 직접 요청을 취소한 경우에도 url이 반한되지 않아 에러를 발생시키기 때문에 이를 감지해 적절히 처리해줘야 합니다.
    • 요청을 직접 취소할 경우 abortController 인스턴스의 signal.aborted 값이 true로 반환되는데요. 먼저 이 경우를 예외처리합니다.
    • 이후 화면에 에러 상태를 표시하기 위해 fileItem 상태를 업데이트하고, 별도로 프로젝트에서 토스트 메세지를 표시하기 위해 onError 콜백에 에러를 전달합니다.

clearFileItem

const clearFileItem = () => { if (!fileItem) return; fileItem.abortController.abort(); setFileItem(null); };
  • 사용자가 직접 X 버튼을 클릭해 업로드를 취소할 때 실행되는 함수입니다.
  • fileItem에 저장된 abortController의 abort() 메서드를 실행하는데요. 그럼 upload 함수에 전달된 signal이 취소 신호를 보내고 진행중이던 요청을 중단시키게 됩니다. 이후에 setFileItem으로 상태를 초기화합니다.

ImageUploadNodeComponent.tsx

이제 ReactNodeViewRenderer에 전달되어 실제로 사용자와 상호작용할 컴포넌트를 구현해야 합니다.

function ImageUploadNode(props: NodeViewProps) { const {accept, maxSize} = props.node.attrs; const inputRef = useRef<HTMLInputElement>(null); const extension = props.extension; const uploadOptions: UploadOptions = { accept, maxSize, upload: extension.options.upload, onError: extension.options.onError, }; const {fileItem, uploadFile, clearFileItem} = useFileUpload(uploadOptions); const handleUpload = async (file: File) => { const url = await uploadFile(file); if (!url) return; const pos = props.getPos(); const fileName = file.name.replace(/\.[^/.]+$/, '') || 'unknown'; props.editor .chain() .focus() .deleteRange({from: pos, to: pos + 1}) .insertContentAt(pos, [ { type: 'image', attrs: { src: url, alt: fileName, title: fileName, }, }, ]) .insertContentAt(pos + 1, {type: 'paragraph'}) .run(); }; const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const files = event.target.files; if (!files || files.length === 0) { extension.options.onError?.(createClientError('NO_IMAGE_SELECTED')); return; } if (files.length > 1) { extension.options.onError?.(createClientError('TOO_MANY_IMAGES_SELECTED')); return; } handleUpload(files[0]); }; const handleClick = () => { if (inputRef.current && !fileItem) { inputRef.current.value = ''; inputRef.current.click(); } }; return ( <NodeViewWrapper className="image-upload-box not-prose py-5" tabIndex={0} onClick={handleClick}> {!fileItem ? ( <ImageUploadDragArea onFile={handleUpload} onError={uploadOptions.onError}> <ImageUploadDropZone maxSize={maxSize} /> <input className="hidden" ref={inputRef} name="file" accept={accept} type="file" onChange={handleChange} /> </ImageUploadDragArea> ) : ( <ImageUploadPreview file={fileItem.file} progress={fileItem.progress} status={fileItem.status} onRemove={clearFileItem} /> )} </NodeViewWrapper> ); } export {ImageUploadNode};

흐름으로 이 컴포넌트의 역할을 살펴보자면 아래와 같아요.

초기화

export type UploadOptions = { maxSize: number; accept: string; upload: (file: File, onProgress: (event: {progress: number}) => void, abortSignal: AbortSignal) => Promise<string>; onError: (error: ClientError) => void; };

먼저 위에서 구현한 useFileUpload 혹을 호출하기에 앞서 훅의 인자인 UploadOptions 타입의 구현체인 옵션 객체를 만들어줘야 하는데요.

// ImageUploadNode.tsx addNodeView() { return ReactNodeViewRenderer(ImageUploadNodeComponent); },

한참 위의 ImageUploadNode 컴포넌트에서 addNodeView를 통해 이 컴포넌트를 렌더링해달라고 등록했었습니다. 이렇게 등록된 컴포넌트의 인자는 NodeViewProps 타입을 가지는데 우리가 사용할 주요 필드는 몇 가지 안됩니다.

  • node: 우리가 직접 정의한 이미지 업로드 노드의 속성을 가지고 있는 필드입니다. addAttributes()에 등록한 값들이 들어옵니다. props.node.attrs.accept, props.node.attrs.maxSize로 접근할 수 있습니다.

    addAttributes() { return { accept: { default: this.options.accept, }, maxSize: { default: this.options.maxSize, }, }; },
  • extension: useEditor를 호출할 때, 익스텐션 배열에 이미지 업로드 노드를 등록할 수 있는데요. 이 때 등록한 옵션 객체가 들어옵니다. 그럼 아래와 같이 등록한 함수를 소비할 수 있습니다.

    const editor = useEditor({ extensions: [ StarterKit, Image.configure({ HTMLAttributes: { class: 'custom-editor-image', }, }), ImageUploadNode.configure({ accept: 'image/*', maxSize: 5 * 1024 * 1024, upload: handleImageUpload, onError: updateError, }), ], }) // ImageUploadNodeComponent.tsx function ImageUploadNodeComponent(props: NodeViewProps) { const {upload, onError} = props.extension.options; }
  • getPos(): 에디터 내에 추가된 이미지 업로드 노드가 문서 모델에서 차지하고 있는 시작 위치를 반환하는 함수입니다.

    • 이미지가 정상적으로 업로드되면 해당 위치에 이미지 업로드 노드를 제거하고 업로드된 이미지로 바꿔줘야 하기 때문인데요.
    • 이미지 업로드는 HTTP 요청을 통해 비동기로 동작하기 때문에 사용자가 업로드 중 게시글을 더 작성할 수 있습니다. 때문에 실제 위치를 정확히 판단하기 위해 해당 함수를 사용합니다.
  • editor: 에디터 인스턴스로 commands, chain 함수를 실행하기 위해 사용합니다.

이제 위에서 살펴본대로 props 인자의 값들을 사용해 uploadOptions 객체를 만들고 useFileUpload 훅을 호출하게 됩니다.

function ImageUploadNodeComponent(props: NodeViewProps) { const {accept, maxSize} = props.node.attrs; const inputRef = useRef<HTMLInputElement>(null); const extension = props.extension; const uploadOptions: UploadOptions = { accept, maxSize, upload: extension.options.upload, onError: extension.options.onError, }; const {fileItem, uploadFile, clearFileItem} = useFileUpload(uploadOptions); // ... }

파일이 없으면?

useFileUpload로 반환된 fileItem은 useState로 선언된 상태값입니다. 이미지를 선택하지 않았다면 우리는 사용자에게 드래그&드롭 혹은 파일탐색기를 통해 이미지를 선택할 수 있는 UI를 제공해줘야 하는데요.

return ( <NodeViewWrapper className="image-upload-box not-prose py-5" tabIndex={0} onClick={handleClick}> {!fileItem ? ( <ImageUploadDragArea onFile={handleUpload} onError={uploadOptions.onError}> <ImageUploadDropZone maxSize={maxSize} /> <input className="hidden" ref={inputRef} name="file" accept={accept} type="file" onChange={handleChange} /> </ImageUploadDragArea> ) : ( {/** 프리뷰 **/} )} </NodeViewWrapper> );

tiptap에서 말하길 익스텐션에 사용되는 노드 뷰의 최상위는 반드시 NodeViewWrapper여야 한답니다. ProseMirror와 리액트 컴포넌트를 연결하기 위해서 사용한다는대요. 굳이 이 내용은 깊게 알아보지 않았습니다.

다시 돌아와서 이렇게 fileItem의 유무에 따라 보여지는 UI를 동적으로 결정합니다. 파일이 없을 경우 드래그&드롭과 파일 탐색기를 통해 이미지를 선택할 수 있어야 하는데요.

각 컴포넌트를 알아보기 전에 전달되는 함수들을 먼저 짚어볼게요.

아 근데 이거 패키지 코드를 뜯어오느라 인식하지 못했던 건데, 아래처럼 그냥 label 태그에 htmlFor 쓰면 굳이 ref 객체를 쓸 필요가 없었을 거 같아요.

<NodeViewWrapper> <ImageUploadDragArea onFile={handleUpload} onError={uploadOptions.onError}> <label htmlFor="image-upload-input" className="w-full h-full"> {/** 굳이 ref 객체를 왜 썼을까? 여기에 스타일링 하면 될 거 같은데.. **/ </label> <input id="image-upload-input" className="hidden" name="file" accept={accept} type="file" onChange={handleChange} /> </ImageUploadDragArea> </NodeViewWrapper>

handleChange

const handleChange = (event: ChangeEvent<HTMLInputElement>) => { const files = event.target.files; if (!files || files.length === 0) { extension.options.onError?.(createClientError('NO_IMAGE_SELECTED')); return; } if (files.length > 1) { extension.options.onError?.(createClientError('TOO_MANY_IMAGES_SELECTED')); return; } handleUpload(files[0]); };

input 태그에 change 이벤트가 발생하면 실행되는 함수로 event 객체가 전달됩니다. 이벤트의 target 속성에 파일이 전달됐는지, 그리고 파일이 2개 이상 전달됐는지를 확인해 예외 처리합니다.

마지막으로 정상적으로 파일이 1개만 들어왔다면 handleUpload 함수에 파일을 전달합니다.

handleUpload

const handleUpload = async (file: File) => { const url = await uploadFile(file); if (!url) return; const pos = props.getPos(); const fileName = file.name.replace(/\.[^/.]+$/, '') || 'unknown'; props.editor .chain() .focus() .deleteRange({from: pos, to: pos + 1}) .insertContentAt(pos, [ { type: 'image', attrs: { src: url, alt: fileName, title: fileName, }, }, ]) .insertContentAt(pos + 1, {type: 'paragraph'}) .run(); };

이미지 업로드, 업로드된 이미지를 에디터 내에 삽입하는 비동기 함수입니다.

  • url: useFileUpload 훅으로부터 반환된 uploadFile 함수는 실제 이미지 url을 반환하길 기대하는 함수입니다. uploadFile의 반환 값을 url 변수에 할당합니다.
  • pos: 현재 이미지 업로드 노드의 위치를 getPos() 함수를 호출해 할당합니다.
  • fileName: 사용자가 선택한 이미지의 이름을 추출하는데요. 로컬 상에 저장된 이름을 특수문자를 제외해 할당합니다.
  • editor: 에디터 인스턴스를 사용해 몇 가지 동작을 묶어 실행하는데요.
    • chain(): 먼저 chain 함수를 호출해 뒤에 몇 가지 동작을 묶겠다고 명시합니다.

    • focus(): 에디터 내로 포커스를 복원시킵니다.

    • deleteRange({from: pos, to: pos + 1}): 지정한 범위의 노드를 제거하는 함수입니다. getPos를 통해 이미지 업로드 노드의 위치를 pos에 할당했는데요. 이 pos 값을 사용해 이미지 업로드 노드를 제거합니다.

    • insertContentAt({type: ‘image’, …}): 제거된 위치에 그대로 이미지 노드를 삽입합니다. 노드 속성의 src, alt, title을 추가합니다. type: ‘image’는 이미지 노드를 의미합니다. (에디터 인스턴스 초기화 시점에 이미지 익스텐션을 추가해야 합니다.)

      const editor = useEditor({ extensions: [ StarterKit, Image.configure({ // <= 이렇게! pnpm add @tiptap/extension-image HTMLAttributes: { class: 'custom-editor-image', }, }), ImageUploadNode.configure({ accept: 'image/*', maxSize: 5 * 1024 * 1024, upload: handleImageUpload, onError: updateError, }), ], })
    • insertContentAt(pos + 1, {type: 'paragraph'}): 이건 선택사항입니다. 이미지 삽입 후 포커스가 이미지에 가기 때문에 타이핑 시 이미지가 사라지는 문제가 간혹 발생하더라구요. 이미지를 추가하고 단락을 하나 추가하고 있습니다.

파일이 있으면?

{!fileItem ? ( {/** 드래그&드롭, 파일탐색기.. **/} ) : ( <ImageUploadPreview file={fileItem.file} progress={fileItem.progress} status={fileItem.status} onRemove={clearFileItem} /> )}

파일이 있으면 ImageUploadPreview 컴포넌트를 통해 업로드 진행률, 파일의 정보, 업로드 취소 등을 제공할 UI를 표시하는데요. 이제 각 컴포넌트들에 대해 기록해보겠습니다.

ImageUploadDragArea.tsx

type Props = { onFile: (file: File) => void; onError: (error: ClientError) => void; children?: React.ReactNode; }; export default function ImageUploadDragArea({onFile, onError, children}: Props) { const [dragging, setDragging] = useState(false); const dragCounter = useRef(0); const handleDrop = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragCounter.current = 0; setDragging(false); const files = event.dataTransfer.files; if (!files || files.length === 0) { onError(createClientError('NO_IMAGE_SELECTED')); return; } if (files.length > 1) { onError(createClientError('TOO_MANY_IMAGES_SELECTED')); return; } onFile(files[0]); }; const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragCounter.current += 1; setDragging(true); }; const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragCounter.current -= 1; if (dragCounter.current === 0) { setDragging(false); } }; const handleDragOver = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); }; return ( <div className={`cursor-pointer border-2 rounded-md border-dashed ${dragging ? 'border-boldBlue' : 'border-gray-400'} hover:border-boldBlue`} onDrop={handleDrop} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} > {children} </div> ); }

제공된 자식 영역을 드래그로 파일을 떨어뜨릴 수 있는 드롭존으로 바꿔주는 컴포넌트로 드래그&드롭 이벤트를 처리합니다.

인자

  • onFile(file: File): 성공적으로 단일 파일이 드롭됐을 때 실행되는 콜백 함수입니다.
  • onError(error: ClientError): 파일이 없거나 2개 이상일 때 등 클라이언트 측에서 검증이 실패했을 때 에러를 보고하는 콜백입니다. (에러의 타입은 프로젝트 특성에 맞게 변경하시면 됩니다.)
  • children: 드롭존 내부에 원하는 UI를 표시하도록 위임합니다. 이 컴포넌트는 오직 드래그&드롭 로직만 처리하도록 분리합니다.

상태관리

const [dragging, setDragging] = useState(false); const dragCounter = useRef(0);
  • dragging: ‘현재 드롭존 위에 파일이 올라와 있어요.’ 를 표시하기 위한 시각적 상태입니다.
  • dragCounter: 자식 요소의 UI 경계에 마우스가 올라가면 dragenter/leave 이벤트가 발생하고 전파 단계에서 부모 요소로 버블링되어 올라가 각각 핸들러 함수를 실행하는데요. 이 현상을 최적화하고자 선언한 ref 객체입니다.

handleDragEnter

const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragCounter.current += 1; setDragging(true); };

dragenter 이벤트를 처리하기 위한 함수입니다. 해당 영역으로 드래그 요소가 들어왔을 때 발생하는 이벤트 인데요. 드래그가 시작되었다는 의미로 dragging 상태를 true로 변경합니다.

이벤트가 발생할 때마다 카운터를 1만큼 증가시킵니다. dragenter 이벤트는 부모 요소에 들어올 때 1번, 내부 자식 요소에 커서가 들락날락 할때마다 계속 발생하기 때문에 객체에 누적시킵니다.

handleDragLeave

const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragCounter.current -= 1; if (dragCounter.current === 0) { setDragging(false); } };

dragleave 이벤트를 처리하기 위한 함수로 드래그 요소가 해당 영역을 벗어나면 발생하는 이벤트입니다.

위에서 설명했듯, 자식 영역의 아이콘, 글자 등에 커서가 올라가면 dragenter/leave 이벤트가 발생합니다. 카운터를 사용하지 않고 바로 dragging 상태를 false로 설정하면 어떻게 동작할까요?

drag-counter-before.gif

  1. 드래그&드롭 영역에 마우스 커서가 올라와 dragging 상태가 true가 됩니다. ⇒ 드래그 중이죠?
  2. UI 표시용 자식 영역에 마우스 커서가 올라오면 dragenter 이벤트가 발생하고 이벤트 전파 단계에서 자식에서 부모로 버블링되어 handleDragEnter 함수가 실행됩니다.
  3. UI 표시용 자식 영역에서 마우스 커서가 벗어나면 dragleave 이벤트가 발생하고 마찬가지로 이벤트 전파 단계에서 부모로 버블링되어 handleDragLeave 함수가 실행되어 dragging 상태가 false가 됩니다. ⇒ 실제로는 드래그 중이지만 드래그 중이지 않다고 표시됩니다.

실제로 드래그 중이지만 외곽선이 깜빡여 사용자는 드래그가 정상적으로 이루어지는지 판단하기 어려워집니다.

하지만 카운터 객체를 통해 드래그 상태를 처리하면 어떻게 동작할까요?

drag-counter-after.gif

  1. 드래그&드롭 영역에 커서가 올라와 dragenter 이벤트가 발생하면 드래그 상태를 true로 변경하며 카운터를 1증가시킵니다.
  2. UI 표시용 자식 영역에 커서가 옮겨가면 dragenter 이벤트가 발생하며 이벤트 전파 단계에서 자식에서 부모로 버블링되어 handleDragEnter 함수가 실행됩니다. ⇒ 카운터 객체를 1증가시킵니다.
  3. UI 표시용 자식 영역에서 마우스 커서가 벗어나면 dragleave 이벤트가 발생하고 마찬가지로 이벤트 전파 단계에서 부모로 버블링되어 handleDragLeave 함수가 실행됩니다. ⇒ 2번에서 자식 영역에서 발생한 dragenter로 카운터를 1증가시켰죠? 이걸 그대로 감소시킵니다.
    • 카운터 값이 0이 아닌 경우 dragging 상태를 false로 변경하지 않아 외곽선이 유지됩니다.
  4. 최종적으로 드래그&드롭 영역을 벗어나면 dragleave 이벤트가 발생하며 카운터를 1만큼 감소시킵니다.
    • 비로소 카운터 값이 0이 되어 dragging 상태를 false로 변경합니다.

handleDragOver

const handleDragOver = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); };

dragover 이벤트를 처리하기 위한 함수인데요. dragover 이벤트는 드래그 요소가 해당 영역 위에서 움직이면 계속 발생하는 이벤트입니다. 그런데 단순히 브라우저의 기본 동작을 막고만 있습니다. 왜 dragover 이벤트를 막을까요?

먼저 브라우저의 기본 동작은 대부분의 요소에 대해 ‘여기 드롭 금지야.’ 입니다. 아래에서도 설명하겠지만, 이 기본 동작을 막지 않으면 drop 이벤트가 발생하지 않습니다.

그래서 이 기본 동작(드롭을 못 하게 하는 동작)을 막아 해당 요소를 유효한 드롭 대상으로 만들어줘야 드롭이 가능해집니다.

handleDrop

const handleDrop = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragCounter.current = 0; setDragging(false); const files = event.dataTransfer.files; if (!files || files.length === 0) { onError(createClientError('NO_IMAGE_SELECTED')); return; } if (files.length > 1) { onError(createClientError('TOO_MANY_IMAGES_SELECTED')); return; } onFile(files[0]); };

drop 이벤트를 처리하기 위한 함수인데요. drop 이벤트는 드래그 요소를 해당 영역에 떨어뜨리면 발생하는 이벤트입니다. 위에서 설명했듯, dragover 이벤트를 처리할 때 브라우저의 기본 동작을 막지 않으면 이 이벤트는 발생하지 않습니다.

drop 이벤트도 마찬가지로 기본 동작을 막아주고 있습니다. 파일을 드롭했을 때 새 페이지를 열어 마치 뷰어처럼 해당 파일을 표시하기 때문에 기본 동작을 막고, 해당 이벤트로 전달된 파일을 직접 처리하기 위함입니다.

드롭이 발생했다는 의미는 곧 드래그가 끝났다는 의미로 볼 수 있기 때문에 드래그 카운터와 dragging 상태를 초기화합니다.

  • event.dataTransfer: 드래그&드롭 작업 중 드래그되고 있는 데이터를 보관하기 위해 사용하는데요. dropEffect, effectAllowed 등 몇 가지 속성이 있지만 우리는 드래그 중인 로컬 파일의 목록을 조회하기 위해 files 속성만 사용합니다.
  • 예외처리: 파일이 없거나 파일이 2개 이상 드롭된 경우 onError 콜백으로 클라이언트 에러를 전송합니다.
  • 검증성공: 검증에 성공한 경우 전달된 onFile 콜백으로 파일을 전달합니다.

ImageUploadDropZone.tsx

type Props = { maxSize: number; }; export default function ImageUploadDropZone({maxSize}: Props) { return ( <div className="w-full h-full my-6 flex flex-col items-center justify-center select-none"> <LucideIcon name="FileImage" className="w-12 h-12 md:w-16 md:h-16 mb-2 text-gray-600" /> <div className="text-center"> <p className="text-sm md:text-base text-gray-600 mb-1">이미지를 드래그하거나 클릭해 업로드할 수 있어요.</p> <p className="text-xs md:text-sm text-gray-500">최대 크기: {maxSize / 1024 / 1024}MB</p> <p className="text-xs md:text-sm text-gray-500">지원되는 파일 형식: JPEG, PNG, GIF, WEBP</p> </div> </div> ); }

정말 기능적인 로직은 단 하나도 섞이지 않은 UI 컴포넌트입니다. 사용자에게 허용 가능한 이미지 타입, 크기 등을 안내합니다.

ImpageUploadPreview.tsx

type Props = { file: File; progress: number; status: 'uploading' | 'success' | 'error'; onRemove: () => void; }; export default function ImageUploadPreview({file, progress, status, onRemove}: Props) { const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); const handleRemove = (event: MouseEvent<HTMLButtonElement>) => { event.stopPropagation(); onRemove(); }; return ( <div className={`relative border rounded-md py-3 px-4 overflow-hidden ${status === 'error' ? 'bg-red-100 border-red-200 text-red-500' : ''}`}> {status === 'uploading' && ( <div className={`absolute inset-0 bg-lightBlue`} style={{width: `${progress}%`, transition: 'all 300ms ease-out'}} /> )} <div className="relative flex justify-between"> <div className="flex items-center gap-2"> <LucideIcon name="FileUp" className="w-8 h-8" /> <div> <p className="text-sm font-semibold mb-1">{file.name}</p> <p className="text-xs text-gray-600">{fileSize}</p> </div> </div> <div className="flex items-center gap-2"> {status === 'uploading' && <p className="text-sm font-semibold">{progress}%</p>} <button onClick={handleRemove}> <LucideIcon name="X" className="w-5 h-5 hover:scale-110 transition-transform" /> </button> </div> </div> </div> ); }

선택한 fileItem이 있을 때 보여지는 프리뷰 컴포넌트인데요. 업로드 중일 경우 전달된 progress(1부터 100까지 전달)를 이용해 프로그레스 바를 표시하고, 선택한 파일의 이름, 크기, 업로드 취소를 위한 취소 버튼 등을 표시해줍니다.

인자

  • file: 화면에 표시할 대상으로 사용자가 선택한 파일입니다.
  • progress: 0부터 100까지 전달되며 업로드 진행률 표시에 사용됩니다.
  • status: 현재 파일 업로드 상태를 나타냅니다.
  • onRemove: X 버튼 클릭 시 호출되는 함수로 업로드 취소와 제거를 담당합니다.

formatFileSize

const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; };

패키지 코드를 뜯어보고 제일 이해가 가지 않았던 코드가 이 함수였습니다. 일단 제가 이해한 바로는 다음과 같아요.

  • 우선 formatFileSize 함수는 파일 용량을 Bytes, KB, MB 등 사람이 읽기 쉬운 단위로 변환하는 함수인데요. Math.log(bytes) / Math.log(1024) 공식을 사용해 현재 파일(이미지)의 용량이 몇 번째 단위(Bytes, KB, MB)에 해당하는지 계산합니다.
  • 자바스크립트의 Math.log 함수는 자연상수 e(2.718...)를 몇 번 제곱했을 때 전달된 수를 표현할 수 있는지 구하는 메서드입니다.

이해를 위해 약간의 예시를 들어보자면

Math.log(1024); => log_e(1024); => 자연상수e를 몇 번 제곱하면 1024가 나오니? => 6.9314...

위 코드는 자연상수 e를 6.932정도 제곱하면 1024가 나오니 6.932라는 값을 반환해요. 비슷하게 접근해보자면

Math.log(2048) / Math.log(1024) => log_e(2048) / log_e(1024) (e를 몇 번 제곱해야 2048?) / (e를 몇 번 제곱해야 1024?) => 로그의 밑 변환 공식에 의해 log_1024(2048) => 1024를 몇 번 제곱해야 2048이니? => 1.1

이렇게 접근이 가능해집니다. 그럼 구체적으로 이미지 파일의 크기가 2KB인 경우와 1MB인 경우는 아래와 같이 동작할 거예요.

// 이미지 파일의 크기가 2048 bytes라면? log_e(2048) / log_e(1024) => 1 => sizes[1] = KB // 이미지 파일의 크기가 1048576 bytes(1MB)라면? log_e(1048576) / log_e(1024) => 2 => sizes[2] = MB

sizes 배열을 확장하면 GB, TB 등 더 큰 단위도 표시할 수 있지만 우리 프로젝트는 5MB까지 지원하니 MB까지만 배열에 표시했습니다.

마지막 줄에서 Math.pow(1024, i)로 실제 값을 변환해 소수점 둘째 자리까지만 표시하고 parseFloat으로 불필요한 0을 제거해요.

결과적으로, 1536 bytes는 '1.5 KB', 1048576 bytes는 '1 MB'처럼 표시할 수 있게 돼요.

익스텐션 연결하기

import ImageUploadNode from '../extension/image-upload'; const editor = useEditor({ extensions: [ StarterKit, Image.configure({ HTMLAttributes: { class: 'custom-editor-image', }, }), ImageUploadNode.configure({ accept: 'image/*', maxSize: 5 * 1024 * 1024, upload: handleImageUpload, onError: updateError, }) });

이렇게 에디터를 초기화할 때, 익스텐션 배열에 가장 처음 다뤘던 Node.create 함수로 생성한 ImageUploadNode를 등록해주면 끝입니다.

실제 업로드는?

글의 서두에서 presigned url을 발급 받아 S3 스토리지에 직접 이미지를 업로드한다고 말씀드렸습니다.

ImageUploadNode는 선택된 이미지 파일을 전달된 upload 콜백으로 전달하고, 성공 및 반환에 따른 UI 표시만을 담당합니다.

실제로 임시 url 발급, 스토리지 업로드, 진행률 등을 표시하기 위해 적절히 함수를 등록해야하는데요.

handleImageUpload

import {getPresigned, uploadImage} from '@/entities/review'; export default async function handleImageUpload( file: File, onProgress: (event: {progress: number}) => void, abortSignal: AbortSignal, ) { const fileType = file.type.split('/')[1]; const {presignedUrl, uuid: imageId} = await getPresigned(fileType); const url = await uploadImage({file, fileType, presignedUrl, imageId, onProgress, abortSignal}); return url; }

upload 속성으로 전달되는 핸들러 함수입니다.

  1. 전달된 파일의 타입을 분리해 fileType 변수에 할당합니다.
  2. 별도로 구현된 getPresigned 함수를 통해 임시 url과 이미지 고유 ID를 반환 받습니다.
    • 해당 함수는 백엔드 서버와 통신을 위해 만들어진 HTTP 요청 함수입니다.

    • 먼저 파일 타입을 서버 측에서 검증하고, 이미지의 고유 ID를 생성합니다.

    • 해당 고유 ID로 스토리지의 어떤 경로에 저장할지 presigned url을 발급합니다.

    • 이미지 고유 ID와 presigned url을 반환 받습니다. 이미지 고유 ID를 함께 반환 받은 이유는 에디터 내에 이미지 src 속성을 즉시 적용하기 위함입니다.

      만약 uuid 값이 jimin-1 이라면? https://cloudfront주소.com/jimin-1 => 즉시 이미지 조회가 가능 <img src="https://cloudfront주소.com/jimin-1" /> => 에디터 내에 서버로 이미지 요청 없이 즉시 이미지 삽입 가능
  3. 별도로 구현된 uploadImage 함수를 통해 완성된 이미지 url을 반환 받고 이를 반환합니다.

uploadImage

export async function uploadImage({ file, fileType, imageId, presignedUrl, onProgress, abortSignal, }: UploadImageProps): Promise<string> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', presignedUrl, true); xhr.upload.onprogress = event => { if (event.lengthComputable) { const progress = Math.round((event.loaded / event.total) * 100); onProgress({progress}); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(`${process.env.NEXT_PUBLIC_CLOUDFRONT_URL}/${imageId}.${fileType}`); } else { reject(createClientError('UPLOAD_FAILED')); } }; xhr.onerror = () => { reject(createClientError('UPLOAD_FAILED')); }; if (abortSignal) { abortSignal.addEventListener('abort', () => { xhr.abort(); reject(createClientError('UPLOAD_CANCELLED')); }); } xhr.setRequestHeader('Content-Type', file.type); xhr.send(file); }); }

제가 http 관련해서 공부할 때 fetch 요청의 경우 응답 스트리밍은 가능한데 요청 진행률은 계산이 불가능하더라구요. 어쩔 수 없이 xhr 객체를 사용하게 됐습니다.

return new Promise((resolve, reject) => {

먼저 해당 함수의 리턴 부분입니다. xhr은 프로미스가 아닌 이벤트 기반으로 동작하는데요. 실제로 내부 코드를 보면 각 이벤트별로 핸들러 함수를 등록해놓은 상태입니다.

프로젝트의 일관된 코드 패턴(async/await)을 위해 프로미스를 반환하도록 구현했습니다. 성공, 실패에 따라 resolve, reject를 호출합니다.

const xhr = new XMLHttpRequest(); xhr.open('PUT', presignedUrl, true);

먼저 서버와 통신을 위해 xhr 객체를 생성하고, 요청을 서버로 보내기 전 초기화를 위해 xhr.open을 사용합니다. 입력한 인자를 순서대로 설명하자면,

  • ‘PUT’: S3 스토리지에 업로드할 때 PUT 메서드를 사용하기 때문에 PUT으로 설정합니다.
  • presignedUrl: 요청을 보낼 url을 작성하는데요. 서버로부터 반환된 임시 url을 사용합니다.
  • true: 해당 요청을 비동기로 수행할지 여부를 boolean 타입으로 전달합니다. false로 설정하면 당연히 요청이 끝날 때까지 브라우저가 멈춥니다.
xhr.upload.onprogress = event => { if (event.lengthComputable) { const progress = Math.round((event.loaded / event.total) * 100); onProgress({progress}); } };

파일이 업로드되는 동안 progress 이벤트가 주기적으로 발생하는데요. 이 핸들러 함수에서 파일 업로드 진행률을 계산해 외부에서 인자로 전달된 onProgress 콜백으로 넘기는 구조입니다.

  • event.lengthComputable: 이 속성은 쉽게 진행률을 계산할 수 있는지 여부로 알면 됩니다. 이 값이 true여야 event.total이 정상적으로 조회돼요.
  • event.loaded: 현재까지 업로드된 바이트 수를 의미합니다.
  • event.total: 업로드할 전체 파일의 바이트 수를 의미합니다.

이제 전달된 이벤트 객체의 값들을 사용해 현재 업로드된 비율을 계산해서 0부터 100 사이의 정수 값을 전달합니다.

xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(`${process.env.NEXT_PUBLIC_CLOUDFRONT_URL}/${imageId}.${fileType}`); } else { reject(createClientError('UPLOAD_FAILED')); } };

xhr.onload에 등록한 함수는 요청이 성공한 경우 실행됩니다. 먼저 응답 상태 코드가 200번대일 경우 성공으로 판단하며 그 외에는 실패로 판단합니다.

  • 업로드에 성공한 경우 resolve를 호출해 프로미스를 성공 상태로 만드는데요. 이미지에 즉시 접근하기 위해 CloudFront URL, 이미지 고유 ID, 이미지 타입을 조합해 반환합니다. 이 url을 이미지 태그의 src 속성에 사용해 화면에 바로 표시하는 구조입니다.
  • 업로드에 실패한 경우 프로미스를 reject를 호출해 프로미스를 실패 상태로 만들며 에러를 발생시킵니다.
xhr.onerror = () => { reject(createClientError('UPLOAD_FAILED')); };

인터넷 연결이 끊기는 등의 문제로 요청 자체가 실패했을 때 실행되는 함수로 reject를 호출해 에러를 발생시킵니다.

if (abortSignal) { abortSignal.addEventListener('abort', () => { xhr.abort(); reject(createClientError('UPLOAD_CANCELLED')); }); }

외부에서 abortSignal이 전달된 경우 요청을 취소할 수 있게 실행되는 코드입니다.

AbortControllerabort()를 호출하면 abortSignal에서 abort 이벤트가 실행되는데요. 여기 등록한 이벤트 리스너가 감지해 진행 중이던 요청을 중단 시키고 업로드가 취소되었다는 에러를 발생시킵니다.

xhr.setRequestHeader('Content-Type', file.type); xhr.send(file);

S3 서버에게 지금 보내는 이미지 파일의 타입을 헤더에 실어 요청을 서버로 전송합니다.

마치며

여기까지 현재 프로젝트에서 어떻게 이미지 업로드 기능을 구현했는지 기록해봤습니다.. 굳이 image-upload-node 패키지를 뜯어서 고칠 필요 없으신 분들은 그냥 깔아서 편하게 사용하시는게 좋지 않나.. 싶기는 합니다.

하지만 현재 코드베이스랑 맞지 않는 부분이 많아서 굳이 수정하게 되었는데 그래도 남의 코드 이해하고 수정하는게 꽤 재밌기는 했습니다.

에디터와 관련된 코드는 이 곳 에서 확인 가능합니다. 여기까지 읽어주셔서 감사합니다…뿅..

tiptap으로 에디터 구현하기

tiptap으로 에디터 구현하기

Next.js의 캐싱 기능 활용하기

Next.js의 캐싱 기능 활용하기