๋ฐ˜์‘ํ˜•

๋“œ๋ž˜๊ทธ ๋“œ๋กญ ์—…๋กœ๋“œ ๊ตฌํ˜„ ํ™”๋ฉด. ๋“œ๋กญ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ์— ๋“ค์–ด์˜ฌ ๋•Œ๋งˆ๋‹ค ๋“œ๋กญ ์˜์—ญ์˜ ๋ฐฐ๊ฒฝ์ƒ‰์ด ๋ฐ”๋€Œ๊ณ  ์žˆ๋‹ค.

์š”์ฆ˜ ๋Œ€๋ถ€๋ถ„ ์›น์‚ฌ์ดํŠธ์—์„œ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•  ๋•Œ ๋งˆ์šฐ์Šค๋กœ ์›ํ•˜๋Š” ํŒŒ์ผ์„ ๋Œ์–ด ๋†“๋Š” ๋“œ๋ž˜๊ทธ&๋“œ๋กญ ๊ธฐ๋Šฅ์„ ์ง€์›ํ•œ๋‹ค. ๋ฆฌ์•กํŠธ์—์„  React DnD ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ์ง€๋งŒ HTML5์—์„œ ์ œ๊ณตํ•˜๋Š” ๋“œ๋ž˜๊ทธ ๋“œ๋กญ API๋ฅผ ์ด์šฉํ•ด์„œ ์ง์ ‘ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. ์ƒ๊ฐ๋ณด๋‹ค ์–ด๋ ต์ง€๋„ ์•Š๋‹ค.

 

๊ธฐ๋ณธ ๊ตฌ์กฐ


๋”๋ณด๊ธฐ
export interface IFileTypes {
  id: number;
  object: File; // File ๊ฐ์ฒด
}

const DragDrop = (
  {
    /* ... */
  },
) => {
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const [files, setFiles] = useState<IFileTypes[]>([]);
  const fileId = useRef<number>(0);

  const onChangeFiles = useCallback(/* onChange ๋กœ์ง */);
  const handleFilterFile = useCallback(/* ํŒŒ์ผ ์‚ญ์ œ ๋กœ์ง */);

  const handleDragOut = useCallback(/* onDragLeave ํ•ธ๋“ค๋Ÿฌ */);
  const handleDragOver = useCallback(/* onDragOver ํ•ธ๋“ค๋Ÿฌ */);
  const handleDrop = useCallback(/* onDrop ํ•ธ๋“ค๋Ÿฌ(onChangeFiles ํ˜ธ์ถœ) */);

  return (
    <div>
      <section>
        <label htmlFor="fileUpload" onDragLeave={} onDragOver={} onDrop={}>
          <input
            hidden
            id="fileUpload"
            type="file"
            accept={}
            multiple={}
            onChange={}
          />
          ํŒŒ์ผ์„ ๋Œ์–ด์„œ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”
        </label>
      </section>

      {files.length > 0 &&
        files.map(({ id, object: { name } }: IFileTypes) => {
          return (
            <section>
              <span>{`๐Ÿ“Ž ${name}`}</span> {/* ์—…๋กœ๋“œํ•œ ํŒŒ์ผ๋ช… */}
              <button>×</button> {/* ํŒŒ์ผ ์‚ญ์ œ ๋ฒ„ํŠผ */}
            </section>
          );
        })}
    </div>
  );
};

export default DragDrop;
  • ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๋ฉด ๋“œ๋กญ ์˜์—ญ ํ•˜๋‹จ์— ์—…๋กœ๋“œํ–ˆ๋˜ ํŒŒ์ผ๋ช… ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ณด์—ฌ์ค€๋‹ค.
  • ๊ฐ ํŒŒ์ผ๋ช… ์šฐ์ธก์— ์žˆ๋Š” × ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์—…๋กœ๋“œํ•œ ํŒŒ์ผ์ด ์‚ญ์ œ๋œ๋‹ค.
  • (์ฐธ๊ณ ) ์—…๋กœ๋“œํ•œ ํŒŒ์ผ์ด ์ด๋ฏธ์ง€ ํ˜•์‹์ด๋ผ๋ฉด FileReader API๋ฅผ ์ด์šฉํ•ด ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ๊ตฌํ˜„ํ•  ์ˆ˜๋„ ์žˆ๋‹ค.

 

์ƒํƒœ


// Dragdrop.tsx
export interface IFileTypes {
  id: number;
  object: File; // File ๊ฐ์ฒด
}

const [isDragging, setIsDragging] = useState<boolean>(false);
const [files, setFiles] = useState<IFileTypes[]>([]);
const fileId = useRef<number>(0); // ์ƒํƒœ๊ฐ€ ๋ณ€ํ•ด๋„ ๋ฆฌ๋ Œ๋”ํ•  ํ•„์š” ์—†์œผ๋ฏ€๋กœ useRef๋กœ ๊ด€๋ฆฌ

 

  • isDragging : ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ ์œ„์— ์œ„์น˜ํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€
  • ๐Ÿ” onDragOver, onDrop ์ด๋ฒคํŠธ๊ฐ€ ๊ฑธ๋ฆฐ ์š”์†Œ๋Š” ๋“œ๋กญ ์˜์—ญ์œผ๋กœ ์ง€์ •๋œ๋‹ค
  • files : ์—…๋กœ๋“œํ•œ ํŒŒ์ผ์ด ๋‹ด๊ธธ ๋ฐฐ์—ด. ๊ฐ ์š”์†Œ๋Š” ํ•˜๋‚˜์˜ File ๊ฐ์ฒด
  • fileId : ์—…๋กœ๋“œํ•œ ํŒŒ์ผ ๋ฒˆํ˜ธ (1๊ฐœ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•  ๋•Œ๋งˆ๋‹ค 1์”ฉ ์ฆ๊ฐ€)

 

onChange ํ•ธ๋“ค๋Ÿฌ


๐Ÿ’ก File ํƒ€์ž… Input ์š”์†Œ์˜ onChange ์ด๋ฒคํŠธ๋Š” ๋งˆ์šฐ์Šค ํด๋ฆญ → ํŒŒ์ผ ์„ ํƒ → ์—…๋กœ๋“œ ํ–ˆ์„ ๋•Œ ํŠธ๋ฆฌ๊ฑฐ๋œ๋‹ค.

const onChangeFiles = useCallback(
  (e): void => {
    const selectFiles: File[] =
      e.type === 'drop'
        ? e.dataTransfer.files // ๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ์œผ๋กœ ์—…๋กœ๋“œํ–ˆ์„ ๋•Œ
        : e.target.files; // ํด๋ฆญํ•ด์„œ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ–ˆ์„ ๋•Œ(e.type === 'change')

    // ํ•„์š”์‹œ ํŒŒ์ผ ์œ ํ˜•, ํฌ๊ธฐ ๋“ฑ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋กœ์ง ์ถ”๊ฐ€(https://dev-gorany.tistory.com/254)
    // ์œ ํšจ์„ฑ์„ ํ†ต๊ณผํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด return

    // FileList ๊ฐ์ฒด๋Š” length ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ€์ง„ ์œ ์‚ฌ ๋ฐฐ์—ด์ด์ž Symbol.iterator๊ฐ€ ๊ตฌํ˜„๋œ ์ดํ„ฐ๋Ÿฌ๋ธ”
    // ๋”ฐ๋ผ์„œ map ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ „๊ฐœ์—ฐ์‚ฐ์ž ํ˜น์€ Array.from์œผ๋กœ ์ง„์งœ ๋ฐฐ์—ด๋กœ ๋งŒ๋“ค์–ด์ค€๋‹ค
    const tempFiles: IFileTypes[] = [...selectFiles].map((f) => ({
      id: fileId.current++,
      object: f,
    }));

    setFiles([...files, ...tempFiles]);
    e.target.value = ''; // ๋™์ผ ํŒŒ์ผ ์‚ญ์ œ/์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•˜๋„๋ก ํ—ˆ์šฉ(ํด๋ฆญ ์—…๋กœ๋“œ ์‹œ input.value ๊ฐ’์ด ํŒŒ์ผ๋ช…์œผ๋กœ ํ• ๋‹น๋จ)
  },
  [acceptFileFormat, files, maxFileLength],
);

 

์ด๋ฒคํŠธ ํƒ€์ž…

File ํƒ€์ž… Input ์š”์†Œ์˜ onChange ์†์„ฑ์— ํ• ๋‹น๋  onChangeFiles ํ•ธ๋“ค๋Ÿฌ๋Š” ์•„๋ž˜ 2๊ฐ€์ง€ ์ƒํ™ฉ์—์„œ ์‹คํ–‰๋œ๋‹ค. โžŠ๋“œ๋ž˜๊ทธ ๋“œ๋กญ โž‹ํด๋ฆญ - ํŒŒ์ผ ์„ ํƒ ์—…๋กœ๋“œ ๋ฐฉ์‹์— ๋”ฐ๋ผ ์ด๋ฒคํŠธ ํƒ€์ž…์ด ๋‹ค๋ฅด๋ฏ€๋กœ ํŒŒ์ผ ๊ฐ์ฒด๊ฐ€ ๋‹ด๊ธฐ๋Š” ๊ณณ๋„ ์ƒ์ดํ•˜๋‹ค.

 

  1. ๋“œ๋กญ ์˜์—ญ ๋งˆ์šฐ์Šค ํด๋ฆญ → ํŒŒ์ผ ์„ ํƒ ํ›„ ์—…๋กœ๋“œ (onChangeFiles ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ)
    • ์ด๋ฒคํŠธ ํƒ€์ž…(e.type) : change
    • File ๊ฐ์ฒด ์œ„์น˜ : event.dataTransfer.files
  2. ํŒŒ์ผ์„ ๋“œ๋กญ ์˜์—ญ์œผ๋กœ ๋“œ๋ž˜๊ทธ / ๋“œ๋กญํ•ด์„œ ์—…๋กœ๋“œ (onDrop ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ์—์„œ onChangeFiles ํ˜ธ์ถœ)
    • ์ด๋ฒคํŠธ ํƒ€์ž…(e.type) : drop
    • File ๊ฐ์ฒด ์œ„์น˜ : event.target.files

 

FileList

์—…๋กœ๋“œํ•œ ํŒŒ์ผ์€ FileList ๊ฐ์ฒด์— ๋‹ด๊ธฐ๊ณ , FileList๋Š” Symbol.iterator ๋ฉ”์„œ๋“œ๊ฐ€ ๊ตฌํ˜„๋œ ์ดํ„ฐ๋Ÿฌ๋ธ”์ด์ž length ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ€์ง„ ์œ ์‚ฌ๋ฐฐ์—ด์ด๋‹ค. ๋”ฐ๋ผ์„œ map ๊ฐ™์€ ๋ฐฐ์—ด ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด Array.from ์ด๋‚˜ ์ „๊ฐœ ์—ฐ์‚ฐ์ž๋ฅผ ์ด์šฉํ•ด ์ง„์งœ ๋ฐฐ์—ด๋กœ ๋งŒ๋“ค์–ด์ฃผ๋Š” ์ž‘์—…์ด ํ•„์š”ํ•˜๋‹ค.

 

FileList์— ๋‹ด๊ธด File ๊ฐ์ฒด(์—…๋กœ๋“œํ•œ ํŒŒ์ผ)

๊ฐ™์€ ํŒŒ์ผ ๋‹ค์‹œ ์˜ฌ๋ฆฌ๊ธฐ

๐Ÿ’ก ๋“œ๋ž˜๊ทธ ๋“œ๋กญ์œผ๋กœ ์—…๋กœ๋“œํ•œ ํŒŒ์ผ์€ <label> ์š”์†Œ์— ๊ฑธ๋ฆฐ ๋“œ๋ž˜๊ทธ ์ด๋ฒคํŠธ๋ฅผ ํ†ตํ•ด ํ•ธ๋“ค๋งํ•˜๋ฏ€๋กœ ์•„๋ž˜ ๋‚ด์šฉ์€ ํ•ด๋‹น๋˜์ง€ ์•Š๋Š”๋‹ค.

 

๋“œ๋กญ ์˜์—ญ ๋งˆ์šฐ์Šค ํด๋ฆญ → ํŒŒ์ผ ์„ ํƒ ํ›„ ์—…๋กœ๋“œํ•˜๋ฉด event.target.value ๊ฐ’์— ๊ฒฝ๋กœ.ํŒŒ์ผ๋ช…์ด ํ• ๋‹น๋œ๋‹ค. ์ƒํƒœ์— ์ถ”๊ฐ€๋œ ๊ธฐ์กด ํŒŒ์ผ์„ ์‚ญ์ œํ•˜๊ณ  ๋™์ผํ•œ ํŒŒ์ผ์„ ๋‹ค์‹œ ์˜ฌ๋ฆฌ๋ฉด ์•„๋ฌด์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š๋Š”๋‹ค. input.value์— ํ• ๋‹น๋œ ํŒŒ์ผ๋ช…๊ณผ ๋™์ผํ•˜๊ธฐ ๋•Œ๋ฌธ์— onChange ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์•„์„œ ๊ทธ๋Ÿฐ๊ฒƒ.

// onChange ํ•ธ๋“ค๋Ÿฌ ๋‚ด๋ถ€์—์„œ ์ฝ˜์†” ์ถœ๋ ฅ ์‹œ
// C:\fakepath\Profile_OpenPeeps.png
console.log(event.target.value);

 

๊ฐ™์€ ํŒŒ์ผ์„ ๋‹ค์‹œ ์˜ฌ๋ ธ์„ ๋•Œ๋„ onChange ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜๋„๋ก ํ•˜๋ ค๋ฉด, onChange ํ•ธ๋“ค๋Ÿฌ์— input.value (event.target.value) ๊ฐ’์„ ๋น„์›Œ์ฃผ๋Š” ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค.

 

๋“œ๋ž˜๊ทธ ๋“œ๋กญ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ


๐Ÿ’ก onDragOver์™€ onDrop ์ด๋ฒคํŠธ๊ฐ€ ๊ฑธ๋ ค์žˆ๋Š” DOM์ด ๋“œ๋กญ ๊ฐ€๋Šฅํ•œ ์˜์—ญ(์œ ํšจํ•œ ๋“œ๋กญ ๋Œ€์ƒ)์ด ๋œ๋‹ค.

 

handleDragOver — ๋“œ๋กญ ์˜์—ญ ์•ˆ

const handleDragOver = useCallback((e: DragEvent<HTMLLabelElement>): void => {
  e.preventDefault(); // ๋“œ๋กญ ํ—ˆ์šฉ(dragover, dragenter ์ด๋ฒคํŠธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋“œ๋กญ ์ด๋ฒคํŠธ๋ฅผ ์ทจ์†Œ ์‹œํ‚ด)
  setIsDragging(true);
}, []);

 

  • ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ(onDragOver, onDrop ์ด๋ฒคํŠธ๊ฐ€ ๊ฑธ๋ฆฐ ์š”์†Œ) ์œ„์— ์žˆ์„ ๋•Œ ํŠธ๋ฆฌ๊ฑฐ
  • ํ•ธ๋“ค๋Ÿฌ๋Š” ๋“œ๋กญ ์š”์†Œ์˜ onDragOver ์†์„ฑ์— ํ• ๋‹น
    ๐Ÿ”๏ธ ์ฐธ๊ณ ๋กœ onDragEnter ์ด๋ฒคํŠธ๋Š” ๋Œ€์ƒ ๊ฐ์ฒด ์œ„์— ์ฒ˜์Œ ์ง„์ž…ํ–ˆ์„ ๋•Œ 1ํšŒ๋งŒ ํŠธ๋ฆฌ๊ฑฐ ๋˜๊ณ , onDragOver ์ด๋ฒคํŠธ๋Š” ๋Œ€์ƒ ๊ฐ์ฒด ์œ„์— ์žˆ๋Š” ๋™์•ˆ ์ˆ˜๋ฐฑ๋ฐ€๋ฆฌ์ดˆ ๊ฐ„๊ฒฉ์œผ๋กœ ๊ณ„์† ํŠธ๋ฆฌ๊ฑฐ๋œ๋‹ค.
  • ํ˜„์žฌ ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ ์œ„์— ์œ„์น˜ํ–ˆ์œผ๋ฏ€๋กœ isDragging ์ƒํƒœ๋ฅผ true๋กœ ๋ณ€๊ฒฝ
  • onDragOver, onDragEnter ์ด๋ฒคํŠธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋“œ๋กญ ์ด๋ฒคํŠธ๋ฅผ ์ทจ์†Œํ•˜๋Š” ๊ธฐ๋ณธ ๋™์ž‘์„ ๊ฐ€์ง. ํŒŒ์ผ์„ ๋“œ๋กญ ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜๊ธฐ ์œ„ํ•ด preventDefault ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ

 

handleDragOut — ๋“œ๋กญ ์˜์—ญ ๋ฐ–

// Dragdrop.tsx
const handleDragOut = useCallback((): void => {
  setIsDragging(false);
}, []);

 

  • ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ(onDragOver, onDrop ์ด๋ฒคํŠธ๊ฐ€ ๊ฑธ๋ฆฐ ์š”์†Œ)์„ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ ํŠธ๋ฆฌ๊ฑฐ
  • ํ•ธ๋“ค๋Ÿฌ๋Š” ๋“œ๋กญ ์š”์†Œ์˜ onDragLeave ์†์„ฑ์— ํ• ๋‹น
  • ํ˜„์žฌ ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ์„ ๋ฒ—์–ด๋‚ฌ์œผ๋ฏ€๋กœ isDragging ์ƒํƒœ๋ฅผ false๋กœ ๋ณ€๊ฒฝ

 

handleDrop — ๋“œ๋กญ ์˜์—ญ์—์„œ ๋“œ๋กญ

// Dragdrop.tsx
const handleDrop = useCallback(
  (e: DragEvent<HTMLLabelElement>): void => {
    e.preventDefault(); // ๋ธŒ๋ผ์šฐ์ € ์ƒˆ ํƒญ์—์„œ ํŒŒ์ผ์ด ์—ด๋ฆฌ๋Š” ๊ธฐ๋ณธ ์ด๋ฒคํŠธ ๋ฐฉ์ง€
    e.stopPropagation(); // ์ด๋ฒคํŠธ ์ „ํŒŒ ๋ฐฉ์ง€(๋งˆ์šฐ์Šค ๊ด€๋ จ ํฌ๋กฌ ํ™•์žฅ ํ”„๋กœ๊ทธ๋žจ์ด ํŠธ๋ฆฌ๊ฑฐ ๋˜๋Š” ํ˜„์ƒ ๋ฐฉ์ง€)

    onChangeFiles(e); // Drop ์‹œ์ ์— ๋งˆ์šฐ์Šค๋กœ ๋“œ๋ž˜๊ทธํ–ˆ๋˜ ํŒŒ์ผ ์ •๋ณด๊ฐ€ e.dataTransfer.files ๊ฐ์ฒด์— ๋‹ด๊น€
    setIsDragging(false);
  },
  [onChangeFiles],
);

 

  • ๋“œ๋กญ ์˜์—ญ์—์„œ(onDragOver, onDrop ์ด๋ฒคํŠธ๊ฐ€ ๊ฑธ๋ฆฐ ์š”์†Œ) ๋งˆ์šฐ์Šค ํด๋ฆญ์„ ํ•ด์ œํ•˜์—ฌ ๋“œ๋กญํ–ˆ์„ ๋•Œ ํŠธ๋ฆฌ๊ฑฐ
  • ํ•ธ๋“ค๋Ÿฌ๋Š” ๋“œ๋กญ ์š”์†Œ์˜ onDrop ์†์„ฑ์— ํ• ๋‹น
  • ๋“œ๋กญ ์‹œ์ ์— ๋งˆ์šฐ์Šค๋กœ ๋“œ๋ž˜๊ทธํ–ˆ๋˜ ํŒŒ์ผ ์ •๋ณด๋Š” event.dataTransfer.files ๊ฐ์ฒด์— ๋‹ด๊น€๋“œ๋กญ ์‹œ์ ์— event.dataTransfer.files ์ฝ˜์†” ์ถœ๋ ฅ ๊ฒฐ๊ณผ

 

๋“œ๋กญ ์‹œ์ ์— event.dataTransfer.files ์ฝ˜์†” ์ถœ๋ ฅ ๊ฒฐ๊ณผ

  • ๋“œ๋กญํ•œ ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•˜๊ธฐ(์ƒํƒœ๋กœ ์ €์žฅ) ์œ„ํ•ด ์ด๋ฒคํŠธ ๊ฐ์ฒด๋ฅผ ์ธ์ž๋กœ ๋„˜๊ฒจ onChangeFiles ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
  • ํŒŒ์ผ ๋“œ๋กญ ํ›„์—” ๋”์ด์ƒ ๋“œ๋ž˜๊ทธ ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ isDragging ์ƒํƒœ๋ฅผ false๋กœ ๋ณ€๊ฒฝ
  • onDrop ์ด๋ฒคํŠธ๋Š” ๋ธŒ๋ผ์šฐ์ € ์ƒˆํƒญ์— ๋“œ๋กญํ•œ ํŒŒ์ผ์„ ์—ฌ๋Š”(Open) ๊ธฐ๋ณธ ๋™์ž‘์„ ๊ฐ€์ง. ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด preventDefault ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
  • crxMouse ๊ฐ™์€ ๋งˆ์šฐ์Šค ์ œ์Šค์ฒ˜ ๋ถ€๊ฐ€๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๋ฉด ๋“œ๋กญ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ ํ›„ ์ด๋ฒคํŠธ๊ฐ€ ์ „ํŒŒ๋ผ์„œ ์ฝ˜์†”์— ์—๋Ÿฌ๋ฅผ ์ฐ๋Š”๋‹ค. ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด stopPropagation ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•ด์„œ ์ด๋ฒคํŠธ ์ „ํŒŒ๋ฅผ ๋ง‰๋Š”๋‹ค.

 

crxMouse ๋ถ€๊ฐ€๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๋ฉด ๋“œ๋กญํ–ˆ์„ ๋•Œ this._drop is not a function ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค

 

๋“œ๋กญ ์˜์—ญ


ํŒŒ์ผ Input ์Šคํƒ€์ผ ์ˆ˜์ •

<input> ํƒœ๊ทธ์˜ type์„ file๋กœ ๋ช…์‹œํ•˜๋ฉด “ํŒŒ์ผ ์„ ํƒ” ๋ฒ„ํŠผ์ด ํ‘œ์‹œ๋œ๋‹ค. accept ์†์„ฑ์—” ํ—ˆ์šฉํ•  ํŒŒ์ผ ์œ ํ˜•์„ .ํ™•์žฅ์ž ํ˜•ํƒœ๋กœ ์ž…๋ ฅํ•œ๋‹ค. ํ™•์žฅ์ž๋Š” ๋Œ€์†Œ๋ฌธ์ž๋ฅผ ๊ตฌ๋ถ„ํ•˜์ง€ ์•Š๋Š”๋‹ค. ์—ฌ๋Ÿฌ ๊ฐ’์„ ์ž…๋ ฅํ•  ๋• ์ฝค๋งˆ , ๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค.

 

ํŠน์ • ํƒ€์ž…(MIME ์œ ํ˜•)์˜ ๋ชจ๋“  ํ™•์žฅ์ž๋ฅผ ํ—ˆ์šฉํ•˜๊ณ  ์‹ถ์œผ๋ฉด ํƒ€์ž…/* ์„ ์ž…๋ ฅํ•œ๋‹ค. ํ—ˆ์šฉํ•˜์ง€ ์•Š์€ ํŒŒ์ผ ์œ ํ˜•์€ ํŒŒ์ผ ์„ ํƒ ์ฐฝ์—์„œ ์„ ํƒํ•  ์ˆ˜ ์—†๋‹ค. (ํŒŒ์ผ ์œ ํ˜• ์ฐธ๊ณ ๊ธ€)  

 

  • ํ™•์žฅ์ž ์ž…๋ ฅ ์˜ˆ์‹œ : .jpg, .png, .pdf (jpg, png, pdf ํŒŒ์ผ ํ—ˆ์šฉ)
  • ํŠน์ • ํƒ€์ž…์˜ ๋ชจ๋“  ํ™•์žฅ์ž ํ—ˆ์šฉ : image/*, video/*, audio/*

 

๊ธฐ๋ณธ์ ์œผ๋กœ 1๊ฐœ ํŒŒ์ผ๋งŒ ์„ ํƒํ•ด์„œ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ multiple ์†์„ฑ์„ true๋กœ ์„ค์ •ํ•˜๋ฉด ์—ฌ๋Ÿฌ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ๋‹ค(multiple ์†์„ฑ์„ ๋ช…์‹œํ•˜์ง€ ์•Š์œผ๋ฉด 1๊ฐœ ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ๋‹ค).

 

file ํƒ€์ž…์˜ <input> ํƒœ๊ทธ๋Š” ๋ธŒ๋ผ์šฐ์ €๋งˆ๋‹ค ์กฐ๊ธˆ์”ฉ ๋‹ค๋ฅธ ๊ธฐ๋ณธ UI๋ฅผ ๊ฐ€์ง„๋‹ค. ์•„์‰ฝ๊ฒŒ๋„ ์ด UI๋Š” CSS ์Šคํƒ€์ผ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†๋‹ค. ์Šคํƒ€์ผ์„ ์ˆ˜์ •ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด <input> ํƒœ๊ทธ๋ฅผ ํ™”๋ฉด์—์„œ ์ˆจ๊ธฐ๊ณ  <label> ํƒœ๊ทธ์— ์›ํ•˜๋Š” ์Šคํƒ€์ผ์„ ์ •์˜ํ•ด์•ผ ๋œ๋‹ค.

 

File ํ•„๋“œ๋Š” ๋ธŒ๋ผ์šฐ์ €๋งˆ๋‹ค ์กฐ๊ธˆ์”ฉ ๋‹ค๋ฅธ ๊ธฐ๋ณธ UI๋ฅผ ๊ฐ€์ง„๋‹ค - ์ถœ์ฒ˜ Hello Inyoung

  1. Label โ‡„ Input ํƒœ๊ทธ ์—ฐ๊ฒฐ
    <label> ํƒœ๊ทธ์˜ htmlFor ์†์„ฑ๊ณผ <input> ํƒœ๊ทธ์˜ id ์†์„ฑ์„ ๋™์ผํ•˜๊ฒŒ ์ž‘์„ฑํ•˜๋ฉด ๋‘ ํƒœ๊ทธ๊ฐ€ ์—ฐ๊ฒฐ๋œ๋‹ค. ๊ทธ๋Ÿผ <label>์˜ textContent ์ฝ˜ํ…์ธ  ์˜์—ญ์„ ํด๋ฆญํ•ด๋„ input ๋ฐ•์Šค๋ฅผ ํ•ธ๋“ค๋ง ํ•  ์ˆ˜ ์žˆ๋‹ค.
  2. Input ํƒœ๊ทธ ์ˆจ๊ธฐ๊ธฐ
    <input> ํƒœ๊ทธ์— hidden ์†์„ฑ์„ ์ถ”๊ฐ€ํ•ด์„œ ์ˆจ๊ฒจ์ง„ ํ•„๋“œ๋กœ ์ •์˜ํ•œ๋‹ค. ๊ทธ๋Ÿผ ํ™”๋ฉด์—์„œ ๋ณด์ด์ง€ ์•Š๋Š”๋‹ค.
  3. Label ํƒœ๊ทธ์— ์›ํ•˜๋Š” ์Šคํƒ€์ผ ์ง€์ •
    Input ํƒœ๊ทธ๋ฅผ ์ˆจ๊น€ ํ•„๋“œ๋กœ ๋งŒ๋“ค์—ˆ์œผ๋ฏ€๋กœ “ํŒŒ์ผ์ฐพ๊ธฐ” ๋ฒ„ํŠผ์ด ๋”์ด์ƒ ํ‘œ์‹œ๋˜์ง€ ์•Š๋Š”๋‹ค. ์ด์ œ <label> ํƒœ๊ทธ์— ์›ํ•˜๋Š” ์Šคํƒ€์ผ์„ ์ง€์ •ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

 

Tailwind CSS๋กœ ๋“œ๋กญ ์˜์—ญ ์Šคํƒ€์ผ ์ •์˜ํ•œ ์˜ˆ์‹œ โ–ผ

<div className={/*...*/}>
  <section
    className={classnames(
      'w-full h-[138px] border-2 border-dashed rounded-lg active:border-blue-400',
      { 'bg-blue-100 border-blue-400': isDragging }, // ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ์— ์žˆ์„ ๋•Œ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ
    )}
  >
    <label
      className="cursor-pointer h-full grid place-content-center text-[#4E5968]"
      htmlFor="fileUpload" // label โ‡„ input ํƒœ๊ทธ ์—ฐ๊ฒฐ (input์ด ์•ˆ์ชฝ์— ์žˆ์–ด์„œ ์•ˆ์ ์–ด๋„ ๋˜์ง€๋งŒ ์ฐธ๊ณ ์šฉ์œผ๋กœ ๊ธฐ์žฌ)
      // ...
    >
      <input
        type="file"
        hidden // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ๋กœ ์ •์˜(ํ™”๋ฉด์—์„œ ๊ฐ์ถค)
        id="fileUpload"
        // ...
      />
      ํŒŒ์ผ์„ ๋Œ์–ด์„œ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”
    </label>
  </section>

  {/* ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ ์ƒ๋žต... */}
</div>;

 

๐Ÿ’ก radio ํƒ€์ž…์˜ <input>์€ 1๊ฐœ๋งŒ ์„ ํƒ ํ•  ์ˆ˜ ์žˆ๊ณ , checkbox ํƒ€์ž…์˜ <input>์€ ์—ฌ๋Ÿฌ ๊ฐœ ์„ ํƒ ํ•  ์ˆ˜ ์žˆ๋‹ค. name ์†์„ฑ์€ ์ฒดํฌ๋ฐ•์Šค์˜ ์ด๋ฆ„์„ ๋‚˜ํƒ€๋‚ด๋ฉฐ ๊ฐ™์€ ๋ถ„๋ฅ˜์˜ ์ฒดํฌ๋ฐ•์Šค๋ฅผ ๊ทธ๋ฃน์œผ๋กœ ๋ฌถ์„ ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค. React / Vue์—์„œ ํŠน์ • <input> ํƒœ๊ทธ๋ฅผ ์‹๋ณ„ํ•  ๋•Œ name ์†์„ฑ์„ ํ™œ์šฉํ•˜๊ธฐ๋„ ํ•œ๋‹ค. form ํƒœ๊ทธ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•˜๋ฉด <input>์˜ name, value ์†์„ฑ ๊ฐ’์ด ...?name=value ํ˜•ํƒœ๋กœ ์ „์†ก๋œ๋‹ค(์ฐธ๊ณ ).

 

๐Ÿ’ก <label> ํƒœ๊ทธ์™€ <input> ํƒœ๊ทธ๋ฅผ ์—ฐ๊ฒฐํ•˜๋ฉด <label>์˜ textContent ์˜์—ญ๋งŒ ํด๋ฆญํ•ด๋„ input ์ฒดํฌ๋ฐ•์Šค๋ฅผ ํ•ธ๋“ค๋งํ•  ์ˆ˜ ์žˆ๋‹ค. <label> ํƒœ๊ทธ์˜ for ์†์„ฑ๊ณผ <input> ํƒœ๊ทธ์˜ id ์†์„ฑ ๊ฐ’์„ ๋™์ผํ•˜๊ฒŒ ์ž…๋ ฅํ•˜๋ฉด ๋‘ ํƒœ๊ทธ๋ฅผ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค. ์ ‘๊ทผ์„ฑ์„ ๊ณ ๋ คํ•ด <input>๊ณผ <label> ํƒœ๊ทธ๋Š” ๊ฐ™์ด ์“ฐ๋Š”๊ฒŒ ์ข‹๋‹ค. <input> ํƒœ๊ทธ๊ฐ€ <label> ์•ˆ์ชฝ์— ์žˆ๋‹ค๋ฉด ์—ฐ๊ฒฐ๋œ ์ƒํƒœ๊ฐ€ ๋˜๋ฏ€๋กœ for, id๋ฅผ ์ž…๋ ฅํ•˜์ง€ ์•Š์•„๋„ ๋œ๋‹ค.

 

ํ•ธ๋“ค๋Ÿฌ ์—ฐ๊ฒฐ / accet, multiple ์†์„ฑ ์ง€์ •

Input ํƒœ๊ทธ๋Š” ์ˆจ๊ฒจ์ง„ ํ•„๋“œ๋กœ ์ •์˜ํ•ด์„œ ํ™”๋ฉด์— ๋ณด์ด์ง€ ์•Š์œผ๋ฏ€๋กœ, Input ํƒœ๊ทธ์™€ ์—ฐ๊ฒฐ๋œ Label ํƒœ๊ทธ์— ๋“œ๋ž˜๊ทธ ๋“œ๋กญ ๊ด€๋ จ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ• ๋‹นํ•œ๋‹ค. ๋งˆ์šฐ์Šค๋กœ ํด๋ฆญํ–ˆ์„ ๋•Œ๋„ ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์ฒ˜๋ฆฌ ํ•˜๊ธฐ ์œ„ํ•ด Input ํƒœ๊ทธ์˜ onChange ์†์„ฑ์— onChangeFiles ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ• ๋‹นํ•œ๋‹ค.

 

๐Ÿ’ก accept, multiple ์†์„ฑ์€ Input ํƒœ๊ทธ์— ์ง€์ •ํ–ˆ์œผ๋ฏ€๋กœ Label ํƒœ๊ทธ์—์„œ ์ด๋ค„์ง€๋Š” ๋“œ๋ž˜๊ทธ ๋“œ๋กญ ์•ก์…˜์—” ์ ์šฉ๋˜์ง€ ์•Š๋Š”๋‹ค. ๋”ฐ๋ผ์„œ onChangeFiles ํ•ธ๋“ค๋Ÿฌ์—์„œ ํ•ด๋‹น ์กฐ๊ฑด์„ ๋”ฐ๋กœ ์ถ”๊ฐ€ํ•ด์ค˜์•ผ ํ•œ๋‹ค.

// ์•„๋ž˜ ๋‘ ๋ณ€์ˆ˜๋Š” DragDrop.ts ์ปดํฌ๋„ŒํŠธ๊ฐ€ props๋กœ ๋ฐ›์Œ
// acceptFileFormat: Array<'jpg'|'png'|'...'>
// maxFileLength: number

<label
  // ...
  onDragLeave={handleDragOut} // ๋“œ๋กญ ์˜์—ญ์„ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ
  onDragOver={handleDragOver} // ๋“œ๋กญ ์˜์—ญ ์œ„์— ์žˆ์„ ๋•Œ
  onDrop={handleDrop} // ๋“œ๋กญ ์˜์—ญ์—์„œ ๋“œ๋กญํ–ˆ์„ ๋•Œ
>
  <input
    // ...
    accept={acceptFileFormat.map((t) => `.${t}`).join()} // ํ—ˆ์šฉํ•  ํŒŒ์ผ ํ™•์žฅ์ž *ex) 'jpg,png'*
    multiple={maxFileLength > 1} // ์—ฌ๋Ÿฌ ํŒŒ์ผ ์—…๋กœ๋“œ ํ—ˆ์šฉ ์—ฌ๋ถ€. ๊ธฐ๋ณธ๊ฐ’ false
    onChange={onChangeFiles} // ๋งˆ์šฐ์Šค๋กœ ํด๋ฆญํ•ด์„œ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
  />
  ํŒŒ์ผ์„ ๋Œ์–ด์„œ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”
</label>

 

ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ


ํŒŒ์ผ ์‚ญ์ œ ํ•ธ๋“ค๋Ÿฌ

id๋ฅผ ์ธ์ž๋กœ ๋ฐ›์•„, filter ๋ฉ”์„œ๋“œ๋กœ ํ•ด๋‹น id๋ฅผ ๊ฐ€์ง„ ํŒŒ์ผ ๊ฐ์ฒด๋ฅผ ๊ฑธ๋Ÿฌ๋‚ธ๋‹ค(์‚ญ์ œ).

const handleFilterFile = useCallback(
  (id: number): void => {
    setFiles(files.filter((file: IFileTypes) => file.id !== id));
  },
  [files],
);

 

ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ ๋ Œ๋”๋ง

์—…๋กœ๋“œํ•œ ํŒŒ์ผ์„ ๋‹ด์•„๋‘๋Š” files ์ƒํƒœ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ Œ๋”๋งํ•œ๋‹ค. ํŒŒ์ผ๋ช…์€ ๊ฐ ํŒŒ์ผ ๊ฐ์ฒด์˜ name ์†์„ฑ์—์„œ ์ฐธ์กฐํ•  ์ˆ˜ ์žˆ๋‹ค. × ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ํ•ด๋‹น ํŒŒ์ผ์˜ id๋ฅผ ์ธ์ž๋กœ ๋„˜๊ฒจ ํŒŒ์ผ ์‚ญ์ œ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ˜ธ์ถœํ•ด์„œ ์‚ญ์ œํ•œ๋‹ค.

<div className={`text-sm ${classNameProps}`}>
  {/* ๋“œ๋กญ ์˜์—ญ(input/label ์ƒ๋žต)... */}

  {files.length > 0 &&
    files.map(({ id, object: { name } }: IFileTypes) => {
      return (
        <section
          key={id}
          className="bg-[#E7F1FD] rounded-lg mt-3 p-4 flex items-center justify-between text-[#327ADF]"
        >
          <span>{`๐Ÿ“Ž ${name}`}</span>
          <button type="button" onClick={() => handleFilterFile(id)}>
            ×
          </button>
        </section>
      );
    })}
</div>

 

Custom Hook ์œผ๋กœ ๋ถ„๋ฆฌ


๋“œ๋ž˜๊ทธ ๋“œ๋กญ ์ด๋ฒคํŠธ ๋กœ์ง๋ฅผ ์ปค์Šคํ…€ ํ›…์œผ๋กœ ๋”ฐ๋กœ ๋ถ„๋ฆฌํ•  ์ˆ˜๋„ ์žˆ๋‹ค. ์ปค์Šคํ…€ ํ›…์€ โžŠref ๊ฐ์ฒด์™€ โž‹๋“œ๋กญ ์˜์—ญ ์œ„์— ์œ„์น˜ํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€์— ๋Œ€ํ•œ isDragging ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค. ์ด ๋‘˜์€ ์ปค์Šคํ…€ ํ›…์„ ํ˜ธ์ถœํ•œ ๊ณณ์—์„œ ๋ฐ˜ํ™˜๋ฐ›์•„ ์‚ฌ์šฉํ•œ๋‹ค.

 

Hook์ด ๋งˆ์šดํŠธ ๋˜๋ฉด ์ธ์ž๋กœ ๋ฐ›์€ ref ๊ฐ์ฒด์˜ current ์†์„ฑ์— onDragOver ๊ฐ™์€ ๋“œ๋ž˜๊ทธ ๊ด€๋ จ ์ด๋ฒคํŠธ๋ฅผ ๋“ฑ๋กํ•œ๋‹ค. ๋ฐ˜๋Œ€๋กœ Hook์ด ์–ธ๋งˆ์šดํŠธ๋์„ ๋• ๋“ฑ๋กํ•œ ์ด๋ฒคํŠธ๋ฅผ ํ•ด์ œํ•œ๋‹ค.

 

๋“œ๋ž˜๊ทธ ๊ด€๋ จ ์ด๋ฒคํŠธ๋ฅผ ๋“ฑ๋กํ•œ ๋’ค onDrop ์ด๋ฒคํŠธ๊ฐ€ ํŠธ๋ฆฌ๊ฑฐ ๋˜๋ฉด ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ฐ›์€ onChange ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. ํ•ธ๋“ค๋Ÿฌ๋ฅผ ํ˜ธ์ถœํ•  ๋• ์ด๋ฒคํŠธ ๊ฐ์ฒด๋ฅผ ์ธ์ž๋กœ ์ „๋‹ฌํ•œ๋‹ค.

// hooks/useDragDrop.tsx
export default function useDragrop<T extends HTMLElement>(
  onChangeHandler: UseDragDropProps,
) {
  const [isDragging, setIsDragging] = useState(false); // ๋“œ๋ž˜๊ทธ ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ ์œ„์— ์œ„์น˜ํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€
  const dragRef = useRef<T | null>(null);

  const handleDragOut = useCallback(/* onDragLeave ํ•ธ๋“ค๋Ÿฌ */);
  const handleDragOver = useCallback(/* onDragOver ํ•ธ๋“ค๋Ÿฌ */);
  const handleDrop = useCallback(/* onDrop ํ•ธ๋“ค๋Ÿฌ(onChangeHandler ํ˜ธ์ถœ) */);

  const initDragEvents = useCallback(() => {
    if (dragRef.current) {
      dragRef.current.addEventListener('dragover', handleDragOut);
      // dragleave(onDragLeave), drop(onDrop) ์ด๋ฒคํŠธ ๋“ฑ๋ก ์ƒ๋žต...
    }
  }, [dragRef, handleDragOut, handleDragOver, handleDrop]);

  const resetDragEvents = useCallback(() => {
    if (dragRef.current) {
      dragRef.current.removeEventListener('dragover', handleDragOut);
      // dragleave(onDragLeave), drop(onDrop) ์ด๋ฒคํŠธ ๋“ฑ๋ก ํ•ด์ œ ์ƒ๋žต...
    }
  }, [dragRef, handleDragOut, handleDragOver, handleDrop]);

  useEffect(() => {
    initDragEvents(); // Hook ๋งˆ์šดํŠธ ์‹œ ์ด๋ฒคํŠธ ๋“ฑ๋ก
    return () => resetDragEvents(); // Hook ์–ธ๋งˆ์šดํŠธ์‹œ ์ด๋ฒคํŠธ ๋“ฑ๋ก ํ•ด์ œ
  }, [initDragEvents, resetDragEvents]);

  return [isDragging, dragRef] as const; // onDragOver ์—ฌ๋ถ€, ref ๊ฐ์ฒด ๋ฐ˜ํ™˜
}
๋”๋ณด๊ธฐ
import { useCallback, useEffect, useRef, useState } from 'react';

interface UseDragDropProps {
  (e: DragEvent): void;
}

export default function useDragDrop<T extends HTMLElement>(
  onChangeHandler: UseDragDropProps,
) {
  const [isDragging, setIsDragging] = useState(false);
  const dragRef = useRef<T | null>(null);

  // ๋Œ€์ƒ ๊ฐ์ฒด ์˜์—ญ์„ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ
  const handleDragOut = useCallback((_e: DragEvent) => {
    setIsDragging(false);
  }, []);

  // ๋Œ€์ƒ ๊ฐ์ฒด ์œ„์— ์žˆ์„ ๋•Œ(์œ„์— ์žˆ๋Š” ๋™์•ˆ ๊ณ„์† ํ˜ธ์ถœ)
  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault(); // ๋“œ๋กญ ํ—ˆ์šฉ(dragover, dragenter ์ด๋ฒคํŠธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋“œ๋กญ ์ด๋ฒคํŠธ๋ฅผ ์ทจ์†Œ ์‹œํ‚ด)
    setIsDragging(true);
  }, []);

  // ๋งˆ์šฐ์Šค ํด๋ฆญ์„ ํ•ด์ œํ•˜์—ฌ ๋“œ๋กญํ–ˆ์„ ๋•Œ
  const handleDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault(); // ๋ธŒ๋ผ์šฐ์ €์˜ ์ƒˆ ํƒญ์—์„œ ํŒŒ์ผ์ด ์—ด๋ฆฌ๋Š” ๊ธฐ๋ณธ ์ด๋ฒคํŠธ ๋ฐฉ์ง€
      e.stopPropagation(); // ์ด๋ฒคํŠธ ์ „ํŒŒ ๋ฐฉ์ง€(๋งˆ์šฐ์Šค ๊ด€๋ จ ํฌ๋กฌ ํ™•์žฅ ํ”„๋กœ๊ทธ๋žจ์ด ํŠธ๋ฆฌ๊ฑฐ ๋˜๋Š” ํ˜„์ƒ ๋ฐฉ์ง€)

      onChangeHandler(e); // Drop ์‹œ์ ์— ๋งˆ์šฐ์Šค๋กœ ๋“œ๋ž˜๊ทธํ–ˆ๋˜ ํŒŒ์ผ ์ •๋ณด๊ฐ€ e.dataTransfer.files ๊ฐ์ฒด์— ๋‹ด๊น€
      setIsDragging(false);
    },
    [onChangeHandler],
  );

  const initDragEvents = useCallback(() => {
    if (dragRef.current) {
      dragRef.current.addEventListener('dragleave', handleDragOut);
      dragRef.current.addEventListener('dragover', handleDragOver);
      dragRef.current.addEventListener('drop', handleDrop);
    }
  }, [dragRef, handleDragOut, handleDragOver, handleDrop]);

  const resetDragEvents = useCallback(() => {
    if (dragRef.current) {
      dragRef.current.removeEventListener('dragleave', handleDragOut);
      dragRef.current.removeEventListener('dragover', handleDragOver);
      dragRef.current.removeEventListener('drop', handleDrop);
    }
  }, [dragRef, handleDragOut, handleDragOver, handleDrop]);

  useEffect(() => {
    initDragEvents();
    return () => resetDragEvents();
  }, [initDragEvents, resetDragEvents]);

  return [isDragging, dragRef] as const;
}

 

DragDrop.ts ์ปดํฌ๋„ŒํŠธ์—์„  onChange ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ธ์ž๋กœ ๋„˜๊ฒจ Hook์„ ํ˜ธ์ถœํ•œ ๋’ค onDragOver ์—ฌ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” isDragging ์ƒํƒœ์™€ dragRef ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ ๋ฐ›๋Š”๋‹ค. dragRef ๊ฐ์ฒด๋Š” ๋“œ๋กญ ์˜์—ญ์œผ๋กœ ์ง€์ •๋  ํƒœ๊ทธ์˜ ref ์†์„ฑ์— ํ• ๋‹นํ•œ๋‹ค. isDragging์€ ๋“œ๋ž˜๊ทธ ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ ์œ„์— ์œ„์น˜ ํ–ˆ๋Š”์ง€์— ๋”ฐ๋ผ ๋ฐฐ๊ฒฝ์ƒ‰์„ ๋ณ€๊ฒฝํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค.

// DragDrop.tsx
const DragDrop = (
  {
    /* ... */
  },
) => {
  const onChangeFiles = useCallback(/* onChange ๋กœ์ง */);
  const [isDragging, dragRef] = useDragDrop<HTMLLabelElement>(onChangeFiles);
  // ...์ƒ๋žต

  return (
    <div className={/* ... */}>
      <section
        className={classnames(
          'w-full h-[138px] border-2 border-dashed rounded-lg active:border-blue-400',
          { 'bg-blue-100 border-blue-400': isDragging }, // ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ ์œ„์— ์žˆ์„ ๋•Œ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ
        )}
      >
        <label
          className="cursor-pointer h-full grid place-content-center text-[#4E5968]"
          htmlFor="fileUpload"
          ref={dragRef}
        >
          {/* <input onChange={onChangeFiles} ... /> ์ƒ๋žต */}
          ํŒŒ์ผ์„ ๋Œ์–ด์„œ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”
        </label>
      </section>

      {/* ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ ์ƒ๋žต... */}
    </div>
  );
};

export default DragDrop;

 

์ „์ฒด ์ฝ”๋“œ


๋”๋ณด๊ธฐ
// lib/utils.ts
import { ERR_FILE_NUM_EXCEEDED, ERR_NOT_ALLOWED_EXTENSION } from './constants';
import { AcceptFormat, IFileTypes } from '../types';
// AcceptFormat: 'jpg' | 'jpeg' | 'png' | 'gif' | 'webp' | 'tiff';

export const checkAcceptFormat = (files: File[], acceptList: string[]) => {
  return [...files].every(
    ({ type }) => acceptList.includes(type.split('/')[1]), // image/png → ['image', 'png']
  );
};

interface ValidCheckParams {
  files: IFileTypes[]; // ์ด๋ฏธ ์—…๋กœ๋“œํ•œ ํŒŒ์ผ์ด ๋‹ด๊ธด ๋ฆฌ์ŠคํŠธ (DragDrop ์ปดํฌ๋„ŒํŠธ์—์„œ ๊ด€๋ฆฌ)
  selectFiles: File[]; // ์—…๋กœ๋“œํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ์ž๊ฐ€ ํ˜„์žฌ ์„ ํƒํ•œ(ํ˜น์€ ๋“œ๋ž˜๊ทธ ๋“œ๋กญ) ํŒŒ์ผ ๋ฆฌ์ŠคํŠธ
  maxFileLength: number; // ์ตœ๋Œ€ ์—…๋กœ๋“œ ํŒŒ์ผ ๊ฐœ์ˆ˜ (DragDrop ์ปดํฌ๋„ŒํŠธ๊ฐ€ props๋กœ ๋ฐ›์Œ)
  acceptFileFormat: AcceptFormat[]; // ์—…๋กœ๋“œ ํ•  ์ˆ˜ ์žˆ๋Š” ํŒŒ์ผ ํฌ๋งท (DragDrop ์ปดํฌ๋„ŒํŠธ๊ฐ€ props๋กœ ๋ฐ›์Œ)
}

export const validCheck = ({
  files,
  selectFiles,
  maxFileLength,
  acceptFileFormat,
}: ValidCheckParams) => {
  const result = { isValid: true, errMsg: '' };

  if (files.length >= maxFileLength || selectFiles.length > maxFileLength) {
    result.isValid = false;
    result.errMsg = `${ERR_FILE_NUM_EXCEEDED} \n`; // '์ตœ๋Œ€ ํŒŒ์ผ ๊ฐฏ์ˆ˜๋ฅผ ์ดˆ๊ณผ ํ•˜์˜€์Šต๋‹ˆ๋‹ค'
  }

  if (!checkAcceptFormat(selectFiles, acceptFileFormat)) {
    result.isValid = false;
    result.errMsg += ERR_NOT_ALLOWED_EXTENSION; // 'ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ํ™•์žฅ์ž์ž…๋‹ˆ๋‹ค'
  }

  return result;
};
๋”๋ณด๊ธฐ
// DragDrop.tsx
import classnames from 'classnames';
import React, {
  DragEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import { IFileTypes, SignContent } from 'types';
import {
  DD_UPLOAD_GUIDE,
  MULTIPLICATION_SIGN,
  UPLOAD_COMPLETE,
} from 'lib/constants';
import { validCheck } from 'lib/utils';

interface DragDropProps {
  classNameProps: string;
  handler: (arg: boolean) => void;
  acceptFileInfo: SignContent['file'];
}

const DragDrop = ({
  classNameProps,
  handler,
  acceptFileInfo: { maxFileLength, acceptFileFormat },
}: DragDropProps) => {
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const [files, setFiles] = useState<IFileTypes[]>([]);
  const fileId = useRef<number>(0); // ์ƒํƒœ๊ฐ€ ๋ณ€ํ•ด๋„ ๋ฆฌ๋ Œ๋”ํ•  ํ•„์š” ์—†์œผ๋ฏ€๋กœ useRef ๋กœ ๊ด€๋ฆฌ

  const onChangeFiles = useCallback(
    (e): void => {
      const selectFiles: File[] =
        e.type === 'drop'
          ? e.dataTransfer.files // ๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ์œผ๋กœ ์—…๋กœ๋“œํ–ˆ์„ ๋•Œ
          : e.target.files; // ํด๋ฆญํ•ด์„œ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ–ˆ์„ ๋•Œ(e.type === 'change')

      // ํ•„์š”์‹œ ํŒŒ์ผ ํฌ๊ธฐ ๋“ฑ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋กœ์ง ์ถ”๊ฐ€(https://dev-gorany.tistory.com/254)
      const { isValid, errMsg } = validCheck({
        files,
        selectFiles,
        maxFileLength,
        acceptFileFormat,
      });
      if (!isValid) return alert(errMsg);

      // FileList ๊ฐ์ฒด๋Š” length ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ€์ง€๋Š” ์œ ์‚ฌ ๋ฐฐ์—ด์ด์ž Symbol.iterator ๊ฐ€ ๊ตฌํ˜„๋œ ์ดํ„ฐ๋Ÿฌ๋ธ”
      // ๋”ฐ๋ผ์„œ map ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ „๊ฐœ์—ฐ์‚ฐ์ž ํ˜น์€ Array.from ์œผ๋กœ ์ง„์งœ ๋ฐฐ์—ด๋กœ ๋งŒ๋“ค์–ด์ค€๋‹ค
      const tempFiles: IFileTypes[] = [...selectFiles].map((f) => ({
        id: fileId.current++,
        object: f,
      }));

      setFiles([...files, ...tempFiles]);
      e.target.value = ''; // ๋™์ผ ํŒŒ์ผ ์‚ญ์ œ/์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•˜๋„๋ก ํ—ˆ์šฉ(ํด๋ฆญ ์—…๋กœ๋“œ ์‹œ value ๊ฐ’์ด ํŒŒ์ผ๋ช…์œผ๋กœ ํ• ๋‹น๋จ)
      alert(UPLOAD_COMPLETE);
    },
    [acceptFileFormat, files, maxFileLength],
  );

  const handleFilterFile = useCallback(
    (id: number): void => {
      setFiles(files.filter((file: IFileTypes) => file.id !== id));
    },
    [files],
  );

  // ๋Œ€์ƒ ๊ฐ์ฒด ์˜์—ญ์„ ๋ฒ—์–ด๋‚ฌ์„ ๋•Œ
  const handleDragOut = useCallback((): void => {
    setIsDragging(false);
  }, []);

  // ๋Œ€์ƒ ๊ฐ์ฒด ์œ„์— ์žˆ์„ ๋•Œ(์œ„์— ์žˆ๋Š” ๋™์•ˆ ๊ณ„์† ํ˜ธ์ถœ)
  const handleDragOver = useCallback((e: DragEvent<HTMLLabelElement>): void => {
    e.preventDefault(); // ๋“œ๋กญ ํ—ˆ์šฉ(dragover, dragenter ์ด๋ฒคํŠธ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋“œ๋กญ ์ด๋ฒคํŠธ๋ฅผ ์ทจ์†Œ ์‹œํ‚ด)
    setIsDragging(true);
  }, []);

  // ๋งˆ์šฐ์Šค ํด๋ฆญ์„ ํ•ด์ œํ•˜์—ฌ ๋“œ๋กญํ–ˆ์„ ๋•Œ
  const handleDrop = useCallback(
    (e: DragEvent<HTMLLabelElement>): void => {
      e.preventDefault(); // ๋ธŒ๋ผ์šฐ์ €์˜ ์ƒˆ ํƒญ์—์„œ ํŒŒ์ผ์ด ์—ด๋ฆฌ๋Š” ๊ธฐ๋ณธ ์ด๋ฒคํŠธ ๋ฐฉ์ง€
      e.stopPropagation(); // ์ด๋ฒคํŠธ ์ „ํŒŒ ๋ฐฉ์ง€(๋งˆ์šฐ์Šค ๊ด€๋ จ ํฌ๋กฌ ํ™•์žฅ ํ”„๋กœ๊ทธ๋žจ์ด ํŠธ๋ฆฌ๊ฑฐ ๋˜๋Š” ํ˜„์ƒ ๋ฐฉ์ง€)

      onChangeFiles(e); // Drop ์‹œ์ ์— ๋งˆ์šฐ์Šค๋กœ ๋“œ๋ž˜๊ทธํ–ˆ๋˜ ํŒŒ์ผ ์ •๋ณด๊ฐ€ e.dataTransfer.files ๊ฐ์ฒด์— ๋‹ด๊น€
      setIsDragging(false);
    },
    [onChangeFiles],
  );

  useEffect(() => {
    const len = files.length;
    handler(len > 0 && len <= maxFileLength);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [files]);

  return (
    <div className={`text-sm ${classNameProps}`}>
      <section
        className={classnames(
          'w-full h-[138px] border-2 border-dashed rounded-lg active:border-blue-400',
          { 'bg-blue-100 border-blue-400': isDragging }, // ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ์— ์žˆ์„ ๋•Œ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ
        )}
      >
        <label
          className="cursor-pointer h-full grid place-content-center text-[#4E5968]"
          htmlFor="fileUpload"
          onDragLeave={handleDragOut}
          onDragOver={handleDragOver}
          onDrop={handleDrop}
        >
          <input
            hidden // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ๋กœ ์ •์˜
            type="file"
            id="fileUpload"
            accept={acceptFileFormat.map((t) => `.${t}`).join()} // ๋งˆ์šฐ์Šค๋กœ ํด๋ฆญํ•ด์„œ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
            multiple={maxFileLength > 1} // ์—ฌ๋Ÿฌ ํŒŒ์ผ ์—…๋กœ๋“œ ํ—ˆ์šฉ ์—ฌ๋ถ€. ๊ธฐ๋ณธ๊ฐ’ false
            onChange={onChangeFiles} // ๋งˆ์šฐ์Šค๋กœ ํด๋ฆญํ•ด์„œ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
          />
          {DD_UPLOAD_GUIDE}
        </label>
      </section>

      {files.length > 0 &&
        files.map(({ id, object: { name } }: IFileTypes) => {
          return (
            <section
              key={id}
              className="bg-[#E7F1FD] rounded-lg mt-3 p-4 flex items-center justify-between text-[#327ADF]"
            >
              <span>{`๐Ÿ“Ž ${name}`}</span>
              <button type="button" onClick={() => handleFilterFile(id)}>
                {MULTIPLICATION_SIGN}
              </button>
            </section>
          );
        })}
    </div>
  );
};

export default DragDrop;
๋”๋ณด๊ธฐ
// DragDrop.tsx
import classnames from 'classnames';
import React, {
  ChangeEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import { IFileTypes, SignContent } from 'types';
import {
  DD_UPLOAD_GUIDE,
  MULTIPLICATION_SIGN,
  UPLOAD_COMPLETE,
} from 'lib/constants';
import { validCheck } from 'lib/utils';
import useDragDrop from 'hooks/useDragDrop';

interface DragDropProps {
  classNameProps: string;
  handler: (arg: boolean) => void;
  acceptFileInfo: SignContent['file'];
}

const DragDrop = ({
  classNameProps,
  handler,
  acceptFileInfo: { maxFileLength, acceptFileFormat },
}: DragDropProps) => {
  const [files, setFiles] = useState<IFileTypes[]>([]);
  const fileId = useRef<number>(0); // ์ƒํƒœ๊ฐ€ ๋ณ€ํ•ด๋„ ๋ฆฌ๋ Œ๋”ํ•  ํ•„์š” ์—†์œผ๋ฏ€๋กœ useRef ๋กœ ๊ด€๋ฆฌ

  const onChangeFiles = useCallback(
    (e: DragEvent | ChangeEvent<HTMLInputElement>): void => {
      const selectFiles: File[] = [];

      if ('dataTransfer' in e) {
        // FileList ๊ฐ์ฒด๋Š” length ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ€์ง€๋Š” ์œ ์‚ฌ ๋ฐฐ์—ด์ด์ž Symbol.iterator ๊ฐ€ ๊ตฌํ˜„๋œ ์ดํ„ฐ๋Ÿฌ๋ธ”
        // ๋”ฐ๋ผ์„œ map ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ „๊ฐœ์—ฐ์‚ฐ์ž ํ˜น์€ Array.from ์œผ๋กœ ์ง„์งœ ๋ฐฐ์—ด๋กœ ๋งŒ๋“ค์–ด์ค€๋‹ค
        selectFiles.push(...Array.from(e.dataTransfer?.files!)); // ๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ์œผ๋กœ ์—…๋กœ๋“œํ–ˆ์„ ๋•Œ
      } else {
        selectFiles.push(...Array.from(e.target.files!)); // ํด๋ฆญํ•ด์„œ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ–ˆ์„ ๋•Œ(e.type === 'change')
        e.target.value = ''; // ๋™์ผ ํŒŒ์ผ ์‚ญ์ œ/์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•˜๋„๋ก ํ—ˆ์šฉ(ํด๋ฆญ ์—…๋กœ๋“œ ์‹œ value ๊ฐ’์ด ํŒŒ์ผ๋ช…์œผ๋กœ ํ• ๋‹น๋จ)
      }

      // ํ•„์š”์‹œ ํŒŒ์ผ ํฌ๊ธฐ ๋“ฑ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋กœ์ง ์ถ”๊ฐ€(https://dev-gorany.tistory.com/254)
      const { isValid, errMsg } = validCheck({
        files,
        selectFiles,
        maxFileLength,
        acceptFileFormat,
      });
      if (!isValid) return alert(errMsg);

      const tempFiles: IFileTypes[] = selectFiles.map((f) => ({
        id: fileId.current++,
        object: f,
      }));

      setFiles([...files, ...tempFiles]);
      alert(UPLOAD_COMPLETE);
    },
    [acceptFileFormat, files, maxFileLength],
  );

  const handleFilterFile = useCallback(
    (id: number): void => {
      setFiles(files.filter((file: IFileTypes) => file.id !== id));
    },
    [files],
  );

  const [isDragging, dragRef] = useDragDrop<HTMLLabelElement>(onChangeFiles);

  useEffect(() => {
    const len = files.length;
    handler(len > 0 && len <= maxFileLength);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [files]);

  return (
    <div className={`text-sm ${classNameProps}`}>
      <section
        className={classnames(
          'w-full h-[138px] border-2 border-dashed rounded-lg active:border-blue-400',
          { 'bg-blue-100 border-blue-400': isDragging }, // ๋“œ๋ž˜๊ทธ์ค‘์ธ ๋งˆ์šฐ์Šค๊ฐ€ ๋“œ๋กญ ์˜์—ญ์— ์žˆ์„ ๋•Œ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ
        )}
      >
        <label
          className="cursor-pointer h-full grid place-content-center text-[#4E5968]"
          htmlFor="fileUpload"
          ref={dragRef}
        >
          <input
            hidden // ์ˆจ๊ฒจ์ง„ ํ•„๋“œ๋กœ ์ •์˜
            type="file"
            id="fileUpload"
            accept={acceptFileFormat.map((t) => `.${t}`).join()} // ํ—ˆ์šฉํ•  ํŒŒ์ผ ํ™•์žฅ์ž ex) 'jpg,png'
            multiple={maxFileLength > 1} // ์—ฌ๋Ÿฌ ํŒŒ์ผ ์—…๋กœ๋“œ ํ—ˆ์šฉ ์—ฌ๋ถ€. ๊ธฐ๋ณธ๊ฐ’ false
            onChange={onChangeFiles} // ๋งˆ์šฐ์Šค๋กœ ํด๋ฆญํ•ด์„œ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
          />
          {DD_UPLOAD_GUIDE}
        </label>
      </section>

      {files.length > 0 &&
        files.map(({ id, object: { name } }: IFileTypes) => {
          return (
            <section
              key={id}
              className="bg-[#E7F1FD] rounded-lg mt-3 p-4 flex items-center justify-between text-[#327ADF]"
            >
              <span>{`๐Ÿ“Ž ${name}`}</span>
              <button type="button" onClick={() => handleFilterFile(id)}>
                {MULTIPLICATION_SIGN}
              </button>
            </section>
          );
        })}
    </div>
  );
};

export default DragDrop;

 

๊ด€๋ จ ํฌ์ŠคํŒ… / ๋ ˆํผ๋Ÿฐ์Šค


 


๊ธ€ ์ˆ˜์ •์‚ฌํ•ญ์€ ๋…ธ์…˜ ํŽ˜์ด์ง€์— ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”
๋ฐ˜์‘ํ˜•