구미구미
디지털 노마드를 꿈꾸며
구미구미
전체 방문자
오늘
어제
  • 분류 전체보기 (28)
    • 알고리즘 (15)
      • 개념정리 (1)
      • 문제풀이 (13)
    • 웹 개발 (11)
      • HTML · CSS (0)
      • JS · TS (3)
      • React · Next.js (6)
      • Node.js (0)
    • TIL (2)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 파이썬
  • react
  • 리액트
  • typescript
  • I18N
  • 이코테
  • 블록체인
  • next.js
  • 백준
  • 프론트엔드
  • 개발
  • 코딩테스트
  • 백엔드
  • 프로그래머스
  • 자바스크립트
  • nextjs
  • 풀스택
  • 알고리즘
  • 웹개발 #자바스크립트 #타입스크립트 #JS #TS
  • javascript

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
구미구미

디지털 노마드를 꿈꾸며

웹 개발/React · Next.js

[번역] useMemo와 useCallback 제대로 알고 사용하기

2023. 2. 28. 01:59
이 글은 How to useMemo and useCallback: you can remove most of them 를 번역한 글입니다.
의역이 다수 포함되어 있고, 잘못된 내용은 지적해주시면 감사하겠습니다 👍

 

리액트를 처음 접해보신 게 아니라면, 여러분은 아마 useMemo와 useCallback 훅을 이미 알고 계실 것입니다. 그리고 여러분이 중간 규모나 큰 규모의 애플리케이션을 만들고 있다면, 여러분의 앱에는 "이해할 수 없는 useMemo와 useCallback의 체인으로 이루어져 있어 코드를 읽거나 디버깅할 수 없는" 몇몇 부분들이 존재할 것입니다. 이 훅들은 어느새 모든 곳으로 퍼져 나가서 여러분은 단지 이 훅들이 어디에나 있고, 여러분 주변의 모든 사람들이 이 훅들을 사용한다는 이유만으로 이 훅들을 사용하게 될 것입니다.

 

슬픈 점은, 이 모든 것이 완전히 불필요한 작업이라는 것입니다. 아마 90% 정도의 useMemo와 useCallback 훅을 당장 지워도 애플리케이션은 잘 동작할 것이고 심지어 조금 빨라질 수도 있습니다. useMemo와 useCallback 훅이 쓸모없다고 말하려는 것은 아닙니다. 이 훅들이 아주 구체적이고 특정한 몇몇 상황에서만 제한적으로 유용하다는 말입니다.

 

이 글에서 말하려고 하는 것은 이런 것들입니다. 개발자가 useMemo와 useCallback 훅을 사용할 때 저지르는 실수는 어떤 것들이 있는지, 이 훅들의 실제 목적은 무엇이고 이를 적절히 사용하는 방법은 무엇인지 알아보겠습니다.

 

이 훅들이 앱 전반에 무분별하게 퍼지게 되는 데에는 크게 두 가지 원인이 있습니다.

  • 리렌더링을 막기 위해 props를 메모이제이션(memoize)하기
  • 리렌더링이 될 때마다 복잡한 계산이 발생하는 것을 피하기 위해 값을 메모이제이션하기

이것들은 글의 뒷부분에서 다루도록 하고, 우선 useMemo와 useCallback 훅의 목적이 무엇인지 알아보도록 하겠습니다.

 

 

왜 useMemo와 useCallback 훅이 필요한가

답은 간단합니다. 리렌더링 사이에 메모이제이션을 하기 위해서입니다. 어떤 값이나 어떤 함수가 이 훅으로 감싸져 있다면, 리액트는 최초의 렌더에 이 값을 캐시하고, 이후의 렌더링 시점에 저장된 값으로의 레퍼런스를 반환하게 됩니다. 메모이제이션이 없다면 배열, 객체, 함수와 같이 원시값이 아닌 값들은 리렌더링 될 때마다 값이 새로 만들어지게 됩니다. 메모이제이션은 이러한 값들이 비교되는 상황일 때 유용합니다.

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. (위키피디아)

 

다음은 일반적인 자바스크립트 코드입니다.

const a = { "test": 1 };
const b = { "test": 1 };

console.log(a === b); // false

const c = a; // "c" 는 "a"의 참조값

console.log(a === c); // true

 

리액트에 적용하면 이런 식입니다.

const Component = () => {
  const a = { test: 1 };

  useEffect(() => {
    // "a"의 값은 리렌더링 될 때마다 비교됨
  }, [a]);

  // ...
};

a 값은 useEffect의 의존성(dependency) 값입니다. 컴포넌트가 리렌더링 될 때마다 리액트는 의존성 값을 이전 값과 비교할 것입니다. a는 Component 컴포넌트 내부에 정의된 객체이므로, 컴포넌트가 리렌더링 될 때마다 다시 선언될 것입니다. 따라서 리렌더링 전의 a 값과 리렌더링 후의 a 값은 달라지게 되므로 false를 반환하게 될 것이고 useEffect는 리렌더링 될 때마다 다시 실행될 것입니다.

 

이러한 상황을 피하기 위해 a 값을 useMemo 훅을 사용해 감싸줄 수 있습니다.

const Component = () => {
  // a 참조값을 보존
  const a = useMemo(() => ({ test: 1 }), []);

  useEffect(() => {
    // "a" 값이 실제로 바뀌었을 때만 실행됨
  }, [a]);

  // ...
};

이제 useEffect는 a 값이 실제로 바뀌었을 때만 실행됩니다. 위 예제에서는 실행되지 않겠죠.

 

useCallback은 useMemo와 동일하게 동작하지만 함수를 메모이제이션 할 때 더 유용합니다.

const Component = () => {
  // 리렌더링 사이에 onClick 함수를 보존
  const fetch = useCallback(() => {
    console.log('fetch some data here');
  }, []);

  useEffect(() => {
    // fetch 값이 실제로 바뀌었을 때만 실행됨
    fetch();
  }, [fetch]);

  // ...
};

useMemo와 useCallback 훅은 리렌더링 시점에서만 유용하다는 점을 기억해야 합니다. 초기 렌더링 시에 이 훅들은 유용하지 않을 뿐더러 없는 게 더 나을 수도 있습니다. 리액트가 추가로 더 많은 일을 하게 만들기 때문입니다. 그렇다는 것은 여러분의 앱이 초기 렌더링 시에 살짝 더 느려질 수 있다는 뜻입니다. 이러한 훅들이 곳곳에 수백 개씩 존재한다면, 이러한 성능 저하가 눈에 띄게 나타날 수도 있습니다.

 

리렌더링을 막기 위해 props를 메모이제이션 하기

이 훅들의 목적을 알았으니, 이것들의 실용적인 사용법을 알아봅시다. 가장 중요하면서 가장 많이 사용되는 방법 중 하나는 리렌더링을 막기 위해 props를 메모이제이션 하는 것입니다. 여러분의 앱에서 아래와 같은 코드를 본 적 있다면 소리 질러~

 

    1. 리렌더링을 막기 위해 onClick 함수를 useCallback 훅으로 감싸기

const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return (
    <>
      <button onClick={onClick}>Click me</button>
      ... // some other components
    </>
  );
};

    2. 리렌더링을 막기 위해 onClick 함수를 useCallback 훅으로 감싸기

const Item = ({ item, onClick, value }) => <button onClick={onClick}>{item.name}</button>;

const Component = ({ data }) => {
  const value = { a: someStateValue };

  const onClick = useCallback(() => {
    /* do something on click */
  }, []);

  return (
    <>
      {data.map((d) => (
        <Item item={d} onClick={onClick} value={value} />
      ))}
    </>
  );
};

    3. 메모이제이션 된 onClick 함수의 의존성 값인 value를 useMemo로 감싸기

const Item = ({ item, onClick }) => <button onClick={onClick}>{item.name}</button>;

const Component = ({ data }) => {
  const value = useMemo(() => ({ a: someStateValue }), [someStateValue]);
  const onClick = useCallback(() => {
    console.log(value);
  }, [value]);

  return (
    <>
      {data.map((d) => (
        <Item item={d} onClick={onClick} />
      ))}
    </>
  );
};

 

주변 사람들이 위와 같은 코드를 쓰는 것을 본 적이 있거나, 여러분이 이런 코드를 써본 적 있나요? 위와 같은 상황을 훅이 해결해주고, 이런 상황에 훅을 사용하는 것이 적절하다고 생각하시나요? 만약 '그렇다'고 답변하셨다면, 여러분은 useMemo와 useCallback에 사로잡혀 계신 겁니다. 위 모든 예제에서 이 훅들은 불필요하고 쓸데없이 코드를 복잡하게 만들며, 초기 렌더링을 느리게 하면서도 아무것도 방지해주지 못합니다.

 

그 이유를 이해하기 전에, 우리는 리액트가 동작하는 방식에 관한 중요한 점 한 가지를 기억해야 합니다. 컴포넌트는 어떻게 리렌더링할 수 있는 걸까요?

 

컴포넌트는 어떻게 리렌더링 할 수 있을까?

"컴포넌트는 state나 prop 값이 바뀌면 스스로 리렌더링 한다"는 것은 일반적인 상식입니다. 리액트 공식문서에서도 이러한 내용이 적혀있습니다. 저는 이 문장으로부터 "(메모이제이션 등을 통해서) props 값이 바뀌지 않으면, 컴포넌트가 리렌더링 되는 것을 막을 수 있다"는 틀린 결론이 도출된다고 생각합니다.

 

왜냐하면 컴포넌트가 리렌더링 되는 데에는 또 다른 매우 중요한 원인이 있습니다. 바로 부모 컴포넌트의 리렌더링입니다. 반대로 말하면, 컴포넌트가 리렌더링 될 때, 그 컴포넌트의 모든 자식 컴포넌트는 리렌더링 됩니다. 아래와 같은 예제를 봅시다.

const App = () => {
  const [state, setState] = useState(1);

  return (
    <div className="App">
      <button onClick={() => setState(state + 1)}> click to re-render {state}</button>
      <br />
      <Page />
    </div>
  );
};

App 컴포넌트는 몇 개의 state와 자식 컴포넌트를 가집니다. 버튼이 클릭되면 어떤 일이 발생할까요? state가 변경되고, App 컴포넌트의 리렌더링을 유발되어, Page 컴포넌트를 포함한 모든 자식 컴포넌트가 리렌더링 됩니다. Page 컴포넌트는 props를 가지지도 않는데 말입니다.

 

Page 컴포넌트도 자식 컴포넌트를 가진다고 생각해봅시다.

const Page = () => <Item />;

state나 prop 없이 텅 비어있습니다. 하지만 App 컴포넌트가 리렌더링 될 때 Page 컴포넌트도 리렌더링 되며, 자식 컴포넌트인 Item 컴포넌트도 리렌더링 될 것입니다. App 컴포넌트의 state가 변경되면 앱 전반에 걸쳐 리렌더링이 유발되는 것입니다. 코드 샌드박스에서 전체 예제를 참고해보세요.

 

이 연속적인 리렌더링을 막는 유일한 방법은 앱 내부의 컴포넌트를 메모이제이션 하는 것입니다. useMemo 훅을 사용할 수도 있지만 React.memo 유틸을 사용하는 것이 더 좋습니다. React.memo로 컴포넌트를 감싼 경우에만 리액트가 리렌더링 전에 멈춰서 props 값이 바뀌었는지 체크할 것입니다.

 

컴포넌트를 메모이제이션 하기

const Page = () => <Item />;
const PageMemoized = React.memo(Page);

메모이제이션 한 컴포넌트를 state가 바뀌는 App에 사용하기

const App = () => {
  const [state, setState] = useState(1);

  return (
    ... // same code as before
      <PageMemoized />
  );
};

이렇게 하면, 그리고 이렇게 했을 때에만 props가 메모이제이션 됐는지가 중요해집니다.

 

Page 컴포넌트가 onClick이라는 함수 prop을 가진다고 가정해봅시다. 이것을 메모이제이션 하지 않고 Page 컴포넌트에 넘겨준다면 어떻게 될까요?

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    // onClick의 메모이제이션 여부와 관계없이 페이지는 리렌더링 됨
    <Page onClick={onClick} />
  );
};

App은 리렌더링 될 것이고 리액트는 자식 컴포넌트인 Page 컴포넌트를 리렌더링 할 것입니다. onClick 변수가 useCallback으로 감싸져있는지와는 상관 없이 말이죠.

 

그렇다면 Page 컴포넌트를 메모이제이션 한다면 어떨까요?

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = () => {
    console.log('Do something on click');
  };
  return (
    // onClick이 메모이제이션 되지 않았기 때문에 PageMemoized 컴포넌트는 리렌더링 됨
    <PageMemoized onClick={onClick} />
  );
};

App 컴포넌트는 리렌더링 될 것이고, 자식 컴포넌트인 PageMemoized 컴포넌트는 React.memo로 감싸져 있기 때문에 리렌더링을 멈추고 이 컴포넌트의 props 값이 변경되었는지 확인할 것입니다. 이 경우에는 onClick 함수가 메모이제이션 되지 않았기 때문에 prop이 변경되었다고 판단되어 PageMemoized 컴포넌트는 리렌더링 될 것입니다. 마침내 useCallback 훅을 사용할 때가 된 것이죠!

 

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('Do something on click');
  }, []);

  return (
    // onClick 함수가 메모이제이션 되었기 때문에 PageMemoized 컴포넌트는 리렌더링 되지 않음
    <PageMemoized onClick={onClick} />
  );
};

이제 리액트가 PageMemoized 컴포넌트의 prop을 비교할 때 onClick 함수는 변경되지 않았기 때문에 PageMemoized 컴포넌트는 리렌더링 되지 않을 것입니다.

 

PageMemoized 컴포넌트에 메모이제이션 되지 않은 다른 값을 추가하면 어떻게 될까요? 똑같은 일이 발생할 것입니다.

const PageMemoized = React.memo(Page);

const App = () => {
  const [state, setState] = useState(1);
  const onClick = useCallback(() => {
    console.log('Do something on click');
  }, []);

  return (
    // 값이 메모이제이션 되지 않았기 때문에 페이지는 리렌더링 됨
    <PageMemoized onClick={onClick} value={[1, 2, 3]} />
  );
};

리액트는 PageMemoized 컴포넌트의 prop을 확인하기 위해 멈춥니다. onClick 함수는 그대로 유지되지만 value 값은 변하기 때문에 PageMemoized 컴포넌트는 리렌더링 될 것입니다. 링크에서 전체 예제를 확인하고 메모이제이션을 제거했을 때 어떻게 리렌더링이 발생하게 되는지 확인해보세요.

 

위 내용을 고려했을 때, 컴포넌트의 prop을 메모이제이션 해야 하는 경우는 단 한 가지 경우가 있습니다. 컴포넌트의 모든 prop과 컴포넌트 자체가 메모이제이션 되었을 경우입니다. 그 외 모든 경우는 메모리를 낭비하는 것이고 코드를 복잡하게 만들 뿐입니다.

 

다음과 같은 경우 맘 편히 useMemo와 useCallback을 지워버리세요.

  • 값들이 직접적으로, 또는 연속된 의존성을 거쳐 DOM 엘리먼트의 속성으로서 전달되는 경우
  • 값들이 직접적으로, 또는 연속된 의존성을 거쳐 메모이제이션 되지 않은 컴포넌트의 prop으로서 전달되는 경우
  • 값들이 직접적으로, 또는 연속된 의존성을 거쳐 메모이제이션 되지 않은 prop을 가지는 컴포넌트의 prop으로서 전달되는 경우

 

메모이제이션을 고치는 게 아니라 왜 삭제하냐구요? 리렌더링으로 인한 퍼포먼스 문제가 발생했다면 진작에 이 문제를 발견하고 고쳤겠죠? 😉 그리고 퍼포먼스 문제가 없다면 메모이제이션을 고쳐서 사용할 필요가 없습니다. 불필요한 useMemo와 useCallback을 삭제하면 기존의 리렌더링 퍼포먼스를 해치지 않으면서도 코드를 간소화하고 초기 렌더링을 조금 빠르게 할 수 있을 것입니다.

 

렌더링 때마다 복잡한 계산을 피하기

리액트 공식문서에 의하면 useMemo의 주요한 목표는 렌더링 때마다 복잡한 계산이 일어나는 것을 피하는 것이라고 합니다. 하지만 무엇이 "복잡한(expensive)" 계산을 이루는 것인지에 대한 힌트는 나와있지 않습니다. 때문에 개발자들은 useMemo에 꽤나 대부분의 연산을 넣고는 한다. 새 날짜를 만들 때, 배열을 map 하거나 sort 할 때, 객체를 만들 때, 이 모든 경우에 useMemo를 쓰고 있습니다.

 

숫자 몇 개를 봅시다. 최대 250개 정도의 국가들의 리스트가 있고, 우리는 그 배열을 화면에 보여주고 사용자가 정렬할 수 있도록 해준다고 생각해봅시다.

const List = ({ countries }) => {
  const sortedCountries = orderBy(countries, 'name', sort);

  return (
    <>
      {sortedCountries.map((country) => (
        <Item country={country} key={country.id} />
      ))}
    </>
  );
};

궁금한 점은 250개의 엘리먼트를 가진 배열을 정렬하는 것은 복잡한 계산일까요? 그럴 것 같지 않나요? 리렌더링 될 때마다 값을 다시 계산하는 것을 막기 위해 useMemo를 사용해야 할 것 같습니다.

const List = ({ countries }) => {
  const before = performance.now();

  const sortedCountries = orderBy(countries, 'name', sort);

  // this is the number we're after
  const after = performance.now() - before;

  return (
    // same
  )
};

결과는 어떻게 되었을까요? 메모이제이션 없이 CPU가 6배 느려진 상태에서, 약 250개 항목이 있는 이 배열을 정렬하는 데에는 2밀리세컨드도 걸리지 않았습니다. 비교해보면, 그냥 버튼과 텍스트로 이 리스트를 렌더링하는 것은 20밀리세컨드 이상이 걸렸습니다. 10배 이상이죠! 코드 샌드박스를 참고해보세요.

 

실제로는 배열이 훨씬 더 작아지고 렌더링되는 것이 훨씬 더 복잡해져 속도가 느려질 가능성이 높습니다. 따라서 성능 차이는 10배 그 이상으로 커질 것입니다.

 

배열 연산을 메모이제이션 하는 것 대신 우리는 여기에서 가장 복잡한 연산을 메모이제이션 해야 합니다. 컴포넌트를 리렌더링 하고 업데이트하는 것이죠. 이런 식으로 말입니다.

const List = ({ countries }) => {
  const content = useMemo(() => {
    const sortedCountries = orderBy(countries, 'name', sort);

    return sortedCountries.map((country) => <Item country={country} key={country.id} />);
  }, [countries, sort]);

  return content;
};

useMemo는 불필요한 리렌더링을 줄여 20밀리세컨드의 리렌더링 시간을 2밀리세컨드보다 작게 해줍니다.

 

위 내용이 제가 소개하고 싶은 "복잡한" 계산을 메모이제이션 하는 것에 대한 규칙입니다. 실제로 큰 수들의 팩토리얼을 계산하는 게 아니라면 순수한 자바스크립트 함수의 useMemo는 제거하는 것입니다. 자식 컴포넌트를 리렌더링 하는 것은 항상 병목을 일으킬 것입니다. 렌더링 트리의 무거운 부분에만 useMemo를 사용합시다.

 

왜 삭제까지 해야 하나요? 그냥 모든 것을 메모이제이션 하는 게 낫지 않나요? 훅을 전부 지웠을 때 퍼포먼스를 떨어뜨리는 복합적인 영향이 있지 않을까요? 여기서 1밀리세컨드, 저기서 2밀리세컨드, 이런 식이면 전체 앱이 느려지지 않을까요?

 

합당한 의문입니다. 그리고 이러한 생각은 한 가지 사실을 고려하지 않는다면 100% 유효한 의견입니다. 메모이제이션은 공짜가 아니라는 사실 말입니다. 우리가 useMemo를 사용한다면 초기 렌더링 동안 리액트는 결과값을 캐시해야 하고, 이 과정에 시간이 소요됩니다. 물론 이 시간은 적을 수 있습니다. 위 예제에서도 정렬된 국가들을 메모이제이션 하는 데에는 1밀리세컨드가 안 되는 시간이 걸렸습니다. 하지만! 이런 것들이야말로 복합적인 영향을 미치게 될 것입니다. 초기 렌더링은 여러분의 앱이 화면에 가장 먼저 등장했을 때 일어납니다. 모든 컴포넌트가 초기 렌더링을 거쳐 화면에 나타나게 됩니다. 수백 개의 컴포넌트가 존재하는 큰 앱의 경우, 컴포넌트들 중 1/3이 무언가를 메모이제이션 했다면 초기 렌더링 시 추가적으로 10, 20 최악의 경우 100 밀리세컨드가 추가적으로 필요할 것입니다.

 

반면에 리렌더링은 앱의 어떤 한 부분이 변경되었을 때만 발생하는 것입니다. 그리고 구조가 잘 짜여진 앱에서는 앱 전체가 리렌더링 되는 것이 아니라, 변경된 특정한 작은 부분만이 리렌더링 될 것입니다. 얼마나 많은 "계산"들이 이 변경된 파트 내에서 위에서 설명한 경우에 해당될까요? 두세 개? 다섯 개라고 해봅시다. 각각의 메모이제이션을 통해 2밀리세컨드가 조금 안 되게 아낄 수 있을 것이고, 전체적으로 10밀리세컨드 이하의 시간을 아낄 수 있을 것입니다. 우리는 이 10밀리세컨드를 아낄 수도 있고, 아낄 만한 상황이 발생하지 않은 경우 아끼지 못할 수도 있습니다. 이 정도의 시간은 맨눈으로는 체감하기 어려운 시간이고, 자식 컴포넌트의 리렌더링은 이것보다 10배 이상의 시간이 걸리기 때문에 줄어든 시간은 큰 의미가 없어지게 될 것입니다. 항상 발생하는 초기 렌더링을 느리게 만드는 부담도 있는데 말이죠 😔.

 

 


옮기면서

프로젝트를 진행하면서 useMemo와 useCallback이 필요한 상황에 대한 판단이 팀원들끼리 일치하지 않는다는 생각이 들었다. 예를 들어 나는 다른 팀원이 useMemo, useCallback을 사용한 코드를 보면서 '여기서 이게 꼭 필요한 건가?'라는 생각을 종종 했었고, 그 팀원은 내가 훅을 사용하지 않고 작성한 코드를 보면서 비슷한 생각을 했던 것 같다. 슬프게도 사내에서 이런 상황에 대한 생각을 나누고 의견을 통일시킬 수 있는 코드리뷰 시간 등이 없었기 때문에 여러 글을 읽어보며 나의 판단기준이라도 확실히 해두자고 생각했다.

 

그러던 중 발견한 이 글의 제목이 눈에 띄었다. 'you can remove most of them'이라는 부제가 내가 생각하던 '굳이, 꼭 필요한 상황인가?'라는 의문점에 대한 대답인 것처럼 느껴졌기 때문이다. 원문의 글쓴이가 글의 말미에 적어둔 것처럼 우리 프로젝트에서 불필요하게 훅이 사용되고 있는 부분들을 찾아서 없애거나 혹은 제대로 사용하고 싶다는 결심을 하며 글을 마친다.

'웹 개발 > React · Next.js' 카테고리의 다른 글

[Next.js] 국제화(i18n) 자동화 시스템 구축하기  (0) 2023.04.30
[React] Web API를 활용한 영상 녹화 구현하기  (0) 2023.03.31
[React] 전역상태관리 라이브러리 Recoil  (0) 2023.01.30
multer로 AWS S3에 파일 업로드하기  (0) 2022.10.30
[Next.js] "window is not defined" 에러 해결  (0) 2022.05.14
    '웹 개발/React · Next.js' 카테고리의 다른 글
    • [Next.js] 국제화(i18n) 자동화 시스템 구축하기
    • [React] Web API를 활용한 영상 녹화 구현하기
    • [React] 전역상태관리 라이브러리 Recoil
    • multer로 AWS S3에 파일 업로드하기
    구미구미
    구미구미

    티스토리툴바