useCallback, useRef에 대하여,,, 함수의 최적화와 DOM 불러오기를 톺아보자

·2023년 11월 9일

🌙 useCallback 전에...

🌞 Memoization이란?

메모이제이션이란,
컴퓨터가 동일한 계산을 반복해야할 때(주로, React에서 리랜더링이 일어날때이다),
이전에 계산한 값을 메모리에 저장하여 동일한 계산을 하지 않도록,
즉 동일한 입력이 들어오는 재활용하여 속도를 높이는 기술이며 보통 최적화를 위해 사용된다.

🌞 리랜더링의 조건

React의 리렌더링 조건은 간단하게 3가지이다.
1. 자신의 state가 변경될 때
2. 부모 컴포넌트로부터 전달받은 props가 변경될 때
3. 부모 컴포넌트가 리랜더링될 때

🌗 useCallback

🌞 useCallback이란?

리랜더링될 때, 함수를 메모이제이션하기 위해 사용하는 hook이다.
쓰임은 다음과 같다.
const cachedFn = useCallback(fn, [deps1, deps2... ])
Parameter 각각을 살펴보자

  • fn : 메모이제이션하고 싶어하는 함수, React가 첫 렌더링 시에 반환하며, 만약 deps가 바뀌지 않은 경우, 같은 함수를 계속해서 반환하게 돈다.
  • deps : fn이 의존하는 값들의 배열이며, 유동적 값(reactive value)이면 어떤 것이든 상관없이 들어갈 수 있으며, 함수가 의존하는 값의 수만큼 개수가 많아질 수 있다.
    이때, React는 Object.is()를 사용하여, deps의 변화를 캐치한다.

🪐 사용 예시

const memoizedCallback = useCallback( fn, [deps1, deps2,,,] )
//위 코드처럼, 첫번째 인자로 넘어온 함수를, 
//두번째 인자로 넘어온 배열 내의 값이 변경될 때까지 저장하고 재사용할 수 있다.

const add = useCallback( () => x + y, [ x, y ]);

-> 해당 코드에서, x 또는 y의 값이 바뀌면 새로운 함수가 생성되어 add 변수에 할당되고, xy값이 동일하다면 다음 렌더링 때 해당 함수를 재사용하게 된다.

🪐 언제 사용할까?

React에서 useCallback을 사용한다고 무조건 좋을까?
=> 아니다!!
Performance optimizations are not free. They ALWAYS come with a cost but do NOT always come with a benefit to offset that cost.
By. Kent C. Dodds

단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해 useCallback()을 사용하는 것은 큰 의미가 없으며 오히려 손해인 경우가 있다. useCallback 또한 함수를 기억하고, deps의 변화를 감지할 때 추가적인 연산이 필요하다. 따라서, 항상 사용하는 것은 능사가 아니다.

그렇다면 useCallback()은 언제 사용해야 현명할까?

=> 🌍자바스크립트 함수 동등성🌍
-> 함수 또한 객체로 자바스크립트에서 취급이 되기 때문에, 메모리 주소에 의한 참조 비교가 일어난다. 이러한 특성은 React 컴포넌트 함수 내에서
어떤 함수를 다른 함수의 인자로 넘기거나, props로 넘길 때 성능 문제로 이어질 수 있다.
즉, 고유한 함수가 생성될 경우, 부모를 통해 props에 함수를 전달받는 자식 컴포넌트에서는 props가 변경되었다고 판단해 리렌더링이 일어난다.

이에 따른 문제점 상황은 바로 밑에서 확인할 수 있다.

function App() {
  const [name, setName] = useState('');
  const onSave = () => {};

  return (
    <div className="App">
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <Profile onSave={onSave} />
    </div>
  );
}

위의 경우, name이 변경되어, 리렌더링이 발생하면,
onSave함수가 새로 만들어지고, Profile 컴포넌트에 props로 onSave함수가 새로 전달된다.
이때, onSave함수는 같은 값을 반환하지만, 참조가 다른 함수가 되어버리기 때문에 리렌더링이 발생하는데, 이에 따라 연쇄적인 하위 컴포넌트들이 모두 렌더링되는 일이 발생한다.

따라서,

import React, { useCallback, useState } from 'react';
import Profile from './Profile';


function App() {
  const [name, setName] = useState('');
  const onSave = useCallback(() => {
    console.log(name);
  }, [name]);

  return (
    <div className="App">
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <Profile onSave={onSave} />
    </div>
  );
}

useCallback을 사용하여 onSave 함수를 재사용시켜 자식 컴포넌트의 렌더링을 방지할 수 있다.

=> 🌍외부에서 api 값을 가져오는 경우 즉, 의존 배열로 함수를 넘길 때
(useEffect 무한루프 상황 방지)🌍

-> useEffect에서 의존하는 배열(deps)을 함수로 넘기고, 이 함수가 자바스크립트의 특성 때문에, 컴포넌트가 렌더링될 때마다 새로운 참조값으로 변경이 되어, 계속해서 useEffect가 호출되는 악순환 발생할 수 있다.
예시는 다음과 같다.

import React, { useState, useEffect } from "react";

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = () =>
    fetch(`https://your-api.com/users/${userId}`)
      .then((response) => response.json())
      .then(({ user }) => user);

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser]);

  // ...
}

위 경우 fetchUser()함수는 userId값의 변경과 관련없이 Profile 컴포넌트가 랜더링이 발생될 때마다 새로운 참조값으로 변경이 된다. 그렇게 된다면, 다시 useEffect에 의하여 user 상태값이 바뀌고, state값이 바뀌었기 때문에 다시 재랜더링이 일어나, useEffect()가 호출되는 무한 굴레에 빠지게 된다.

import React, { useState, useEffect } from "react";

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  const fetchUser = useCallback(
    () =>
      fetch(`https://your-api.com/users/${userId}`)
        .then((response) => response.json())
        .then(({ user }) => user),
    [userId]
  );

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, [fetchUser]);

  // ...
}

이때, useCallback을 사용하면, Profile 컴포넌트가 리랜더링되더라도 fetchUser 함수의 참조값을 동일하게 유지시킬 수 있다. 이에 따라 userId가 변동될 때에만 fetchUser에 새로운 함수가 할당되어 무한 루프를 방지할 수 있다.

또 다른 예시를 살펴보자

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions];

해당 코드에서, useEffect deps안에createOption은 함수이기 때문에
참조값이 바뀌어 리랜더링 될 때마다 바뀌게 된다.
이와 같은 경우도 createOptionuseCallback으로 감싸주면 무한루프의 참사를 막을 수 있다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions = useCallback(()=> {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]);

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions];

❄️위 코드에서 useCallback을 쓰지 않고, 무한루프를 막을 수 있는 방법이 있다면, 무엇일까? ❄️

=> 🌍커스텀 훅을 만들 때🌍
-> 이 경우 useCallback을 사용하면,
필요할 때만 함수를 리렌더링하기 때문에 최적화시킬 수 있다.

예시는 다음과 같다.

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

위 코드에서, dispatch가 바뀔 때에만 navigategoBack이 리렌더링되므로,
useRouter가 리렌더링될 때마다 참조값이 바뀌어 함수가 리렌더링되는 일을 막아 최적화시킬 수 있다.

🌸결론은!!🌸

🌺 자식 컴포넌트에 props로 함수를 전달하는 경우
🌺 외부에서 값을 가져오는 api를 호출하는 경우
🌺 Custom hook을 최적화하는 경우
주로 사용된다.
다른 경우에는 오히려 손해가 일어날 수도 있다.

🪐 React.memo vs useMemo vs useCallback

☃️ React.memo

컴포넌트를 메모이제이션한다.
부모 컴포넌트로부터 넘겨받은 props가 같다면 메모이제이션 해둔 내용을 재사용하여
가상 DOM에서 랜더링 시에 달라진 부분을 확인하지 않는다.
하지만, 부모 컴포넌트로 부터 넘겨받는 props에 객체(*함수도 객체임)가 포함되어 있다면, javascript의 참조값이 달라지면서, 서로 다른 객체라고 판단한다.
즉!! props가 달라졌다고 판단하기 때문에 계속 리렌더링이 되는 문제가 발생한다.

이를 해결하기 위해 나온 hooks가 ☃️useMemo, useCallback☃️인 것이다.

☃️ useMemo

메모이제이션된 계산된 값을 반환한다.

const memoizedValue = useMemo(() => fn, [deps] )

deps로 지정한 값이 변하게 되면, fn함수를 실행하고 함수의 반환 값을 반환한다.
따라서 주로 객체를 memoization함으로써 재계산하는 로직이 복잡하다면, 불필요한 계산을 하는 것을 막을 수 있다.

☃️ useCallback

메모이제이션된 함수 그 자체를 반환한다.

const memoizedFunciton = useCallback(() => fn, [deps] )

React.memo로 감싸준 자식 컴포넌트에게 함수를 prop으로 넘길 경우, 넘겨받는 함수를 useCallback으로 감싸주면 deps가 바뀌는 경우를 제외하고 항상 동일한 객체를 넘겨주게 된다.

useMemo와 useCallback을 함께 쓰면 시너지가 올라가는 경우도 있다.

import { useMemo, useCallback } from 'react';

function ProductPage({ productId, referrer }) {
  const product = useData('/product/' + productId);

  const requirements = useMemo(() => { 
    return computeRequirements(product);
  }, [product]);

  const handleSubmit = useCallback((orderDetails) => { 
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      <ShippingForm requirements={requirements} onSubmit={handleSubmit} />
    </div>
  );
}

다음의 코드에서,
useMemo를 사용하여 computeRequirements의 계산된 값을 메모이제이션한다.
이때 product가 변하지 않으면 계속해서 그 값은 재사용될 수 있다.

useCallback을 사용하여 handleSubmit에 메모이제이션한 함수를 반환한다.
따라서, productId, referrer 가 바뀌지 않는 한, 함수 자체는 계속해서 재사용된다.

따라서, ProductPage 컴포넌트가 리랜더링될 때,
deps에 들어있는 값이 변해서가 아니라 참조값이 변해서 값과 함수가 다시 계산, 리랜더링되고
자식 컴포넌트인 ShippingForm이 리렌더링이 일어나는 결과를 막을 수 있다.

🌙 useRef

useRef란 저장 공간, DOM 요소에 접근을 위해 사용되는 리액트훅이며,
Ref는 'reference'의 약자이다.
useRef는 current 속성을 가지고 있는 객체를 반환하는데, 인자로 넘어온 초기값을 current 속성에 할당한다.
이 속성은 값을 변경해도 상태를 변경할 때처럼 React 컴포넌트가 다시 랜더링되지 않는다.
즉, 컴포넌트가 다시 랜더링될 떄에도 current 속성 값이 유실되지 않는다.
const ref = useRef(initialValue)

  • initialValue : ref 객체의 current 프로퍼티 초기 설정값이며, 이 인자는 초기 렌더링 이후부터는 무시된다.
  • current : 처음에 전달한 initialValue 로 설정되고, 나중에 다른 값으로 바뀔 수 있다.

🌞 언제 사용할까?

☃️ 1. 저장공간 (변수 관리)

컴포넌트는 State가 변할 때마다 다시 랜더링을 하여 컴포넌트 내부 변수들이 초기화된다.
따라서 만약 useState에 값을 저장했다면, 당연히 리렌디랑 시 저장한 값도 사라지게 된다.
이때, useRef 안에 저장했다면, useRef 안의 값을 아무리 변경해도 컴포넌트는 렌더링되지 않는다. 즉, State 대신 ref는 불필요한 렌더링을 막을 수 있는 것이다.

  • State 변화 -> 렌더링 -> 컴포넌트 내부 변수 초기화 but 그래도 ref 값은 유지됨
  • ref 변화 -> 렌더링x -> 변수 값 유지 가능

즉,
렌더링 때마다 재설정되는 일반 변수와 달리, 리렌더링 사이에 정보를 저장할 수 있다.
리렌더링을 촉발하는 state 변수와 달리, 변경해도 리렌더링을 촉발하지 않는다.
정보가 공유되는 외부 변수와 달리, 각각의 컴포넌트에 로컬로 저장된다.

예시는 다음과 같다.

const App = () => {
  const ref = useRef(null);

  const onClickButton = () => {
    console.log(re.current);
  };

  return (
    <div>
      <input
        type="text"
        name="keyword"
        ref={ref}
        onChange={e => {
          ref.current = e.target.value;
        }}
      />
      <button onClick={onClickButton}>입력값 확인</button>
    </div>
  );
};

-> 값이 컴포넌트의 전 생애주기를 통틀어 유지가 되기 때문에
리렌더링 시에 값이 초기화되지 않는다.

☃️ 2. DOM 요소 접근

주로 input 요소를 클릭하지 않아도 자동적으로 포커스가 되어 있는 듯한 효과를 주는 것처럼 DOM에 접근하는 것이 가능하다. (document.querySelector()와 비슷)

  • querySelector 대신 useRef를 사용해야 하는 EU
    -> useRef는 항상 올바른 DOM Node로 갱신하는데,
    querySelector는 라이프사이클에 따라, DOM 요소를 올바르게 가져오지 못하는 경우가 생긴다.
    또한 상태가 많아지고 복잡해질 경우, 직접적인 DOM 조작과 React 내부 상태를 혼합하면 테스트와 디버깅이 복잡해지는데, React는 가상돔을 사용해 변경 사항을 일괄적으로 처리하므로, querySelector는 이러한 이점을 사용할 수 없다...
    예시는 다음과 같다.
const App = () => {
  const ref = useRef();
  const element = document.querySelector('.App');

  console.log(ref.current); // undefined
  console.log(element); // null

  useEffect(() => {
    console.log(element); // undefined
  }, [element]);

  useEffect(() => {
    console.log(ref.current); // <div class="App"></div>
  }, [ref]);

  return <div className="App" ref={ref}></div>;
};

🌞 주의할 점

🪐 렌더링 중에는 ref.current 읽거나 쓰지 말것

리액트 컴포넌트는 컴포넌트가 순수 함수처럼 동작하기를 기대하므로,
입력값이 동일하면 완전히 동일한 JSX를 반환해야 한다.
즉, 다른 순서나 다른 인수를 사용하여 호출해도 다른 호출의 결과에 영향을 미치지 않아야 하는데,,,
렌더링 중에 ref를 읽거나 쓰면 이러한 기대가 깨지게 되는 문제가 발생할 수 있다.

대신, 이벤트 핸들러나 effect에서 ref를 읽을 수 있다.

문제가 되는 경우

function MyComponent() {
  myRef.current = 123;
  return <h1>{myOtherRef.current}</h1>;
}

해결책

function MyComponent() {
  useEffect(() => {
    myRef.current = 123;
  });
  function handleClick() {.
    doSomething(myOtherRef.current);
  }
}

렌더링 중에 무언가를 쓰거나 읽어야 한다 => state를 대신 사용하는 걸 추천

리액트공식문서
useRef사용하기
useRef사용법
useCallback

profile
new blog: https://hae0-02ni.tistory.com/

5개의 댓글

comment-user-thumbnail
2023년 11월 10일

메모이제이션 부터 설명이 시작돼서 이해가 쉬웠던 거 같아요.
사용예시에 대한 코드와 언제 사용하는지에 대한 부분을 언급해줘서 좋았습니다. 평소 사용에 익숙하지 않았던 부분까지도 보충 할 수 있었던 글이 아닌가 생각했어요.

custom hook을 만들때 useCallback을 사용한다. 라는 부분이 흥미로웠는데요, 예시코드와 함께 살펴보니 한눈에 잘 들어와서, custom hook을 최적화 할때 저도 꼭 사용해야겠다는 생각이 들었어요.

또한 메모이제이션과 관련된 것들을 비교해줘서 흥미로웠던 거 같습니다.
어쩌다보니 useCallback 관련 이야기만 작성했네요 ㅎㅎ
모두 다 흥미로웠습니다 !!! 좋은 글 감사해요!!!

답글 달기
comment-user-thumbnail
2023년 11월 12일

주제 외에도 다양한 훅을 비교해주신 아티클 잘 봤습니다!

특히, Reat.memo와 useMemo, useCallback에 대해 비교해주신 부분이 가장 많이 와닿았어요! 개인적으로는 useMemo와 useCallback이 비슷한 기능을 하고 있다고 느껴졌었거든요 ..!
useMemo는 메모이제이션 된 계산된 값을 반환하지만, useCallback은 메모이제이션된 함수 그 자체를 반환한다는 점에서부터 차이가 있더라구요. 따라서 useMemo는 주로 객체를 메모이제이션하기 때문에, 재계산하는 로직이 복잡하다면 불필요한 계산을 하는 것을 막아줄 수 있고, 넘겨받는 함수를 useCallback으로 감싸주면 deps가 바뀌는 경우를 제외하고 항상 동일한 객체를 넘겨줄 수 있다는 점을 알 수 있었어요!

fetchUser() 함수를 그냥 구현해서 무한루프에 빠지게된 코드와 useCallback을 활용하여 무한루프에 빠지지 않고, userId가 변동될 때에만 fetchUser에 새로운 함수가 할당된다는게 흥미롭게 다가왔어요 ..! 여기에 useMemo까지 활용해주면 deps에 들어있는 값에 의해서가 아닌, 참조값의 변화에 의해 값과 함수가 다시 계산, 리랜더링된다는 것도 예시를 들어주셔서 잘 이해할 수 있었습니당!

꼼꼼한 아티클 감사합니다! 너무 수고 많으셨어용 :-) 🖤

답글 달기
comment-user-thumbnail
2023년 11월 12일

부모 컴포넌트로부터 전달받은 props가 변경된다는 것이 결국은 부모 컴포넌트 리렌더링되는 거라고도 생각할 수 있을 것 같아요!

단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해 useCallback()을 사용하는 것은 큰 의미가 없으며 오히려 손해인 경우가 있다

이 부분이 정말 중요한 부분이라고 생각하는데 짚어주신 덕분에 한 번 더 생각해볼 수 있었습니다.

useMemo를 포함해서 두 훅이 남용되면 안되는 이유 (왜)에 대해서 알아보았는데 그 이유는 다음과 같다고 하네요

  1. 메모이제이션 자체의 비용: 이 두 Hook을 사용하면 함수와 계산 결과를 캐싱하기 위한 메모리 사용량이 늘어난다. 게다가, 새롭게 계산되는 값이 일정 기간 동안 사용되지 않아도 메모리에 남아 있어야 하므로 메모리 관리의 측면에서 비효율적일 수 있다. (가비지 컬렉터가 무시)
  2. 의존성 배열의 관리: useCallback과 useMemo는 의존성 배열이 필요한데, 이 배열에 들어간 값들이 변경될 때마다 메모이제이션 된 값을 무효화하고 새로 계산한다. 이 과정에서 복잡성이 증가하며, 관리가 미흡한 경우 오히려 성능이 저하될 수 있다.
  3. 남용에 따른 실수: 무분별한 사용으로 인해 모든 함수나 결과값을 메모이제이션하려 할 때 실수가 발생할 가능성이 높아진다. 이로 인해 성능 최적화를 기대하는 대신 버그나 성능 저하를 초래할 수 있는 상황이 생길 수 있다.

저는 useCallback을 혐오하는 면접관도 있다는 이야길 들어서 (그 사람이 맞는건 아니지만 우리는 우리 나름의 코드 철학을 가지고 있어야 반박할 수 있다고 생각합니다) 상세한 아티클을 작성해주신 혜인님께 무한한 감사를 보냅니다

답글 달기
comment-user-thumbnail
2023년 11월 12일

와아... 예시를 통해서 구체적으로 설명해주셔서 특히 useCallback의 사용법과 사용 이유를 이해하는데 너무 큰 도움이 되었어요!ㅠㅠㅠ 또한 useCallback과 useMemo가 기존의 React.memo를 해결하기 위해 나온 것이라는 것도 확실히 짚고 넘어갈 수 있었습니다!
아직 useCallback을 어떻게 적절히 사용하면 좋을지에 대한 감이 잘 안잡혀서 추가로 알아보다가, useMemo와 useCallback을 사용하지 말아야 할 경우에 대해 명확히 정리된 글이 있어서 공유드립니닷.
1. 호스트 컴포넌트 (div, span, a, img, input, button등 HTML element에 대응하는 컴포넌트를 호스트 컴포넌트라고 한다네요!)에 전달하는 항목에 대해 쓰지 말 것. 리액트에서는 여기에 함수 참조가 변경되었는지를 신경쓰지 않는다고 합니다..!!
2. leaft 컴포넌트에는 쓰지 말 것. leaf 컴포넌트는 DOM에서 다른 컴포넌트를 렌더링하지 않는, 말그대로 가장 밑단에 있는 컴포넌트 입니다. 예를들어 return <h1> This is component </h1>와 같이 html태그만 렌더링하는 컴포넌트요!
반대로 사용해야할 상황은 언제인지에 대해, 자식 컴포넌트에 props로 함수를 전달하는 경우, 외부에서 값을 가져오는 api를 호출하는 경우, Custom hook을 최적화하는 경우 라고 정리해주신 것에 좀 더 덧붙여보자면

  • 자식 컴포넌트에서 useEffect가 반복적으로 트리거되는 것을 막고 싶을 때
  • 계산 비용이 많이 들고 사용자의 입력이 mapfilter를 사용했을 때와 같이 이후 렌더링 이후로도 참조적으로 동일할 가능성이 높을 때
  • 매우 큰 트리 구조 내에서 부모의 리렌더링 시 다른 렌더링 전파를 막고 싶을 때
    라고 좀 더 구체화해볼 수 있겠습니다!!

참고 아티클

답글 달기
comment-user-thumbnail
2023년 11월 23일

useCallback을 사용해야 하는 경우에 대해 케이스를 나눠 예시와 함께 설명해 주셔서 너무나도 이해하기 쉬었습니다! 아직 한 번도 useCallback을 사용해 본 적은 없지만 앱잼을 하면서는 한 번 사용을 해보고 싶다는 생각이 들었습니다 :) 아티클 초반 부분에 리랜더링 되는 조건으로 간단하게 3가지 말씀해주셨는데, 간단히 말씀하신 거라 이 외의 조건도 있을 거라고 생각을 했어요!! 그래서 찾아봤습니다. 위의 3가지 말고도 추가로 2가지를 찾았습니다.

  1. 저번 주차에 등장했었던 SCU의 return value가 true일 때.
  2. forceUpdate가 실행될 때.

여기서 forceUpdate는 state나 props가 아닌 다른 값이 변경되었을 때 랜더링을 일으키고 싶을 때 사용하는 거라고 해요! 근데 이는 클래스형 컴포넌트에서만 지원을 하고 있고, 함수형에서는 지원이 되지 않는다고 합니다!

import React from 'react';
  
class App extends React.Component {
  reRender = () => {
    // calling the forceUpdate() method
    this.forceUpdate();
  };
  render() {
  
    console.log('Component is re-rendered');
    return (
      <div>
        <h2>GeeksForGeeks</h2>
        <button onClick={this.reRender}>Click To Re-Render</button>
      </div>
    );
  }
}
export default App;

참고 자료
https://seungddak.tistory.com/109
https://db2dev.tistory.com/entry/React-컴포넌트-강제-Re-render하기-re-render-원리에-대한-이해

실습도 참여 했으면 좋았을 텐데 아쉬운 거 같습니다!! 아티클 잘 읽었습니다 :) 작성하시느라 고생하셨습니다.

답글 달기