[React] React 심화, React Hooks

play·2022년 7월 28일
1

React

목록 보기
7/9
post-thumbnail

Chapter1. React 심화

1-1. Virtual DOM
1-2. React Diffing Algorithm

Chapter2. React Hooks

2-1. Component와 Hook
2-2. useMemo
2-3. useCallback
2-4. Custom Hooks

Chapter3. React 기능

3-1. 코드 분할(Code Spliting)
3-2. React.lazy()와 Suspense



Chapter1. React 심화

📌 Virtual DOM

가상의 DOM 객체로 실제 DOM의 사본 같은 개념

🌱 Virtual DOM 탄생 배경

Real DOM (DOM)

  • DOM : Document Object Model, 즉 '문서 객체 모델'을 뜻한다.
    • 문서 객체 : 브라우저가 JS와 같은 스크립팅 언어가 <html>,<head>,<body>와 같은 태그들에 접근 및 조작할 수 있도록 태그들을 트리구조로 객체화 시킨 것.
    • 즉, DOM은 브라우저가 트리구조로 만든 객체 모델
    • 트리 구조로 DOM객체가 이뤄지므로 JS는 쉽게 DOM 객체에 접근 가능하다.
      • 하지만 조작이 잦아지면 DOM의 렌더링은 브라우저의 구동 능력에 의존하므로 DOM의 조작속도가 느려지게 된다.

DOM의 조작 속도가 느려지는 이유

  • DOM 구조 : 계층적 구조

    • 데이터 저장보단 "저장된 데이터를 효과적으로 탐색"이 목적
  • DOM 변경 및 업데이트 = 브라우저의 렌더링 엔진 또한 화면을 리플로우(Reflow)한다는 것

    • 업데이트 된 요소 + 그 자식요소들에 의해 DOM 트리를 재구축 → 재랜더링 과정을 거쳐 UI 업데이트 해야됨.
      • 리플로우, 리페인트 과정 = 레이아웃 및 페인트에 해당하는 재연산을 해야하므로 속도가 느려짐.
      • 따라서 JavaScript로 조작하는 DOM 요소가 많을수록 모든 DOM업데이트를 리플로우 해야하므로 DOM 업데이트 비용이 많이 들게 됨.
        • 왜? 해당 업데이트 요소만 바꾸는 게 아니라 나머지 컨텐츠로 다시 그리기 때문에.
        • 바뀐 부분만 비교해서 그 부분만 렌더링 하면 안돼? → Virtual DOM 탄생 배경

🌱 Virtual DOM이란

  • 그래서 React에는 모든 DOM 객체에 대응하는 가상의 DOM 객체가 있는 것.
  • 가상 DOM 객체는 실제 DOM 객체와 동일한 속성을 가짐에도 훨씬 가벼운 사본이다.
  • 다만 화면에 표시되는 내용을 실제 DOM 객체처럼 직접 변경하는건 아님
  • 가상 DOM은 가상의 UI 요소를 메모리에 유지시키고,
    그 유지시킨 가상의 UI 요소를 ReactDOM과 같은 라이브러리를 통해
    실제 DOM과 동기화시킨다.
    • 실제 DOM을 조작 = 실제 브라우저 화면에 그리기 → 느림
    • 가상 DOM을 조작 = 실제 DOM처럼 실제로 브라우저 화면에 그리는 것이 아님 → 빠른 속도
    • ex) 가상 돔 조작 : 이사하고 짐 옮기는 과정
      실제 돔 조작 : 머릿속으로 이사하고 짐 옮기는 장면을 그리는 것

가상 DOM이 더 빠른 이유?

  1. React는 새로운 요소가 UI에 추가되면 트리구조로 표현되는 가상의 DOM이 만들어짐
  2. 이러한 요소 상태가 변경되면 다시 새로운 가상 DOM 트리 만들어짐
  3. 이전과 이후의 가상 DOM 차이 비교
  4. 비교 끝나면 가상 DOM은 실제 DOM에 변경을 수행할 수 있는 최상의 방법을 계산
  5. 실제 DOM은 최소한의 작업으로 렌더링하게 됨 = 실제 DOM 업데이트 비용 줄이게 됨 = 브라우저 파워를 덜 씀 = 더 빠른 렌더링 가능

Virtual DOM의 형태

  • 가상 DOM은 추상화된 자바스크립트 객체의 형태를 가진다.
  • 실제 DOM처럼 가상 DOM도 HTML 문서 객체를 기반으로 함
  • 추상화만 되었을 뿐 평범한 자바스크립트 객체로 실제 DOM을 건드리지 않고도 필요한 만큼 자유롭게 조작 가능.
  • DOM 트리 예시
const vDom = {
	tagName: "html",
	children: [
		{ tagName: "head" },
		{ tagName: "body",
			children: [
				tagName: "ul",
				attributes: { "class": "list"},
				children: [
					{
						tagName: "li",
						attributes: { "class": "list_item" },
						textContent: "List item"
					}
				]
			]
		}
	]
}


📌 React Diffing Algorithm

  • React는 하나의 트리를 다른 트리로 변형을 시키는 효율적인 조작 방식을 알아내야만 했는데, 알아낸 조작 방식 알고리즘은 O(n^3)의 복잡도를 가지고 있었다. 1000개의 엘리먼트를 실제 화면에 표시하기 위해 10억(1000^3)번의 비교 연산을 하는 건 비싼 연산이므로,

2개의 가정을 갖고 시간 복잡도 O(n)의 새로운 휴리스틱 알고리즘을 구현해낸다.
1. 각기 서로 다른 두 요소는 다른 트리를 구축할 것이다.
2. 개발자가 제공하는 key 프로퍼티로, 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.

🌱 React가 DOM 트리를 탐색하는 방법

  • 너비 우선 탐색(BFS)의 일종
    • 가까운 지점부터 탐색하고 멀리 있는 지점은 나중에 탐색한다
  • 트리의 레벨 순서대로 순회하는 방식으로 탐색한다 = 같은 레벨(위치)끼리 비교한다

다른 타입의 DOM 엘리먼트인 경우

  • DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적이다(ex.<ul>태그 밑엔 <li>태그만 와야함, <p>태그 안에 <p>태그 중복사용 불가)
  • 자식 태그의 부모 태그도 정해져있으므로, 부모 태그가 달라지면 React는 이전 트리를 버리고 새로운 트리를 구축한다.
    • 그렇기 때문에 이전 DOM 노드들은 전부 파괴되고 새로운 컴포넌트가 실행됨

같은 타입의 DOM 엘리먼트인 경우

  • 재귀적 처리 : 하나의 DOM 노드를 처리한 뒤 React는 뒤이어서 해당 노드들 밑의 자식들을 순차적으로 동시에 순회하면서 차이가 발견될 때마다 변경한다.
  • 같은 타입이라면 최대한 렌더링 하지 않는 방향으로 최소한의 변경 사항만 업데이트 함
    • 이게 가능한 이유 : React가 실제 DOM이 아닌 가상 DOM을 사용해 조작하기 때문
    • 업데이트 할 내용이 생긴다면
      • 1) virtual DOM 내부의 프로퍼티만 수정
      • 2) 업데이트가 끝나면 그때 단 한번 실제 DOM으로의 렌더링을 시도

자식 엘리먼트의 재귀적 처리

  • React는 기존 <ul>과 새로운 <ul>을 비교할 때 자식 노드를 순차적으로 위에서부터 아래로 비교하면서 바뀐 점을 찾는다.
  • 이러한 동작 방식을 고려하지 않고 리스트 첫 부분에 엘리먼트를 새로 넣으면, React는 리스트 전체가 바뀌었다고 받아들이고 전부 버리고 새롭게 렌더링 해버린다. 비효율적인 동작방식
    • 이 문제를 해결하기 위해 key속성을 지원
    • key값이 없는 노드는 비효율적으로 동작할 수 있다.

키(key)

  • key : React는 key를 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 아닌지 확인할 수 있다.
  • key 속성에는 유일한Id를 부여해주면 된다.
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

//key가 2014인 자식 엘리먼트를 처음에 추가
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
  • React는 key 속성을 통해 2014라는 자식 엘리먼트가 새롭게 생겼고, 나머지 2015,2016 키를 가진 엘리먼트는 위치만 이동했다는 걸 파악한다. 그래서 추가된 엘리먼트만 변경한다.


Chapter2. React Hooks

React 16.8 버전부터 추가된 기능
클래스 컴포넌트와 생명주기 메서드를 이용하여 작업을 하던 기존 방식에서 벗어나 함수형 컴포넌트에서도 더 직관적인 함수를 이용하여 작업할 수 있게 만든 기능

📌 Component와 Hook

Function Component와 Class Component

  • Hook은 함수 컴포넌트에서 사용하는 메소드다.
  • 함수 컴포넌트 이전에 클래스 컴포넌트가 있었다.

Class Component

// <Counter /> 컴포넌트를 클래스로 작성한 예
class Counter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
        this.handleIncrease = this.handleIncrease.bind(this);
    }

    handleIncrease = () => {
        this.setState({
            counter: this.state.counter + 1
        })
    }

    render(){
       return (
            <div>
                <p>You clicked {this.state.counter} times</p>
                <button onClick={this.handleIncrease}>
                    Click me
                </button>
            </div>
       ) 
    }
}
  • 복잡해질수록 이해하기 어려움
  • 컴포넌트 사이에서 상태 로직 재사용 어려움
  • JavaScript의 this키워드의 동작 방식을 알아야 하는데, 이 문법을 알지 못하면 정확히 이해하기 어렵게 만들곤 했음.
  • 그래서 React는 클래스 컴포넌트에서 함수 컴포넌트로 넘어가게됨.

  • 이전까지의 함수 컴포넌트는 클래스 컴포넌트와는 다르게 상태 값을 사용하거나 최적화할 수 있는 기능들이 조금 미진했다.
    -> 이 부분을 보완하기 위해 Hook을 도입

Function Component

// <Counter /> 컴포넌트를 함수형 컴포넌트로 작성
function Counter () {
    const [counter, setCounter] = useState(0);

    const handleIncrease = () => {
        setCounter(counter + 1)
    }

    return (
        <div>
            <p>You clicked {counter} times</p>
            <button onClick={handleIncrease}>
                Click me
            </button>
        </div>
    )
}
  • 클래스형 컴포넌트에 비해 훨씬 더 직관적이고, 보기 쉽다.
  • Counter 컴포넌트에서 숫자를 올리기 위해 상태값을 저장하고 사용할 수 있게 해주는 useState() 가 있는데, 이 메서드가 바로 Hook이다.
  • Counter 컴포넌트에서 useState() Hook을 호출해 함수 컴포넌트(function component) 안에 state를 추가한 형태다.
    • 이 state는 컴포넌트가 리렌더링 되어도 그대로 유지될 것이다.
    • 또한 해당 컴포넌트에서 State Hook은 하나만 사용했지만 여러개도 사용 가능하다.

Hook

Hook은 React 16.8에 새로 추가된 기능입니다. Hook은 class를 작성하지 않고도 state와 다른 React의 기능들을 사용할 수 있게 해줍니다.

  • 함수형 컴포넌트에서 상태 값 및 다른 여러 기능을 사용하기 편리하게 해주는 메소드
  • function으로만 React를 사용할 수 있으므로 클래스형 컴포넌트에선 동작 X

Hook 사용 규칙

1. 리액트 함수의 최상위에서만 호출할 것

2. 오직 리액트 함수 내에서만 사용될 것

  • Hook은 React의 함수 컴포넌트 내에서 사용되도록 만들어진 메소드다.
  • 일반 JavaScript 함수 내에서는 정상적으로 작동 못 함

📌 useMemo

렌더링 최적화를 위한 Hook
특정 값(value)를 재사용하고자 할 때 사용하는 Hook

/* useMemo를 사용하기 전에는 꼭 import하기. */
import { useMemo } from "react";

function Calculator({value}){

	const result = useMemo(() => calculate(value), [value]);

	return <>
      <div>
					{result}
      </div>
  </>;
}
  • value 값이 계속 바뀌는 게 아니고 어딘가에 저장을 해뒀다가 다시 꺼내서 쓸 수만 있다면 굳이 calculate 함수를 호출할 필요도 없을 것이다. 여기서 useMemo Hook을 사용할 수 있다.
  • useMemo를 호출하여 해당 함수를 감싸주면 전과 후의 렌더링을 비교해 value값이 동일할 경우 이전 렌더링의 value값을 그대로 재활용하게 된다.
    • 이는 메모제이션(Memoization)과 긴밀한 관계가 있다.

Memoization

  • 기존에 수행한 연산의 결과값을 메모리에 저장을 해두고, 동일한 입력이 들어오면 재활용하는 프로그래밍 기법
  • useMemo는 이 개념을 이용해 복잡한 연산의 중복을 피하고 리액트 앱 성능을 최적화 시킨다.

기본 사용법

const memoizedValue = useMemo(() => computeExpensiveValue(a,b), [a,b]);
  • computeExpensiveValue() 함수의 반환값은 a 또는 b의 값이 변경되는 경우 다음 재렌더링 시에만 변경
  • 호출한 함수의 결과를 반환

실습

이제 이름을 입력해도 add 함수는 호출되지 않는다.


📌 useCallback

렌더링 최적화를 위한 Hook
useMemo와 마찬가지로 메모이제이션 기법을 이용한 Hook
useMemo는 결과값을 재사용하는 Hook이고,
useCallback은 함수의 재사용을 위해 사용하는 Hook
함수의 불필요한 재렌더링을 방지

useCallback Hook을 사용하면 그 함수가 의존하는 값들이 바뀌지 않는 한 기존 함수를 계속해서 반환한다.
즉 x와 y값이 동일하다면 다음 렌더링 때 이 함수를 재사용한다.

/* useCallback를 사용하기 전에는 꼭 import하기 */
import React, { useCallback } from "react";

function Calculator({x, y}){

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

	return <>
      <div>
					{add()}
      </div>
  </>;
}
  • useCallback은 그저 메모리 어딘가에 함수를 꺼내서 호출하는 Hook이기 때문에 단순히 컴포넌트 내에서 함수를 반복 생성하지 않기 위해 사용하는 건 손해일 수도 있다.
    • 그럼 언제 사용해?
      • 자식 컴포넌트의 props로 함수를 전달해줄 때 사용하면 좋음

기본 사용법

const memoizedCallback = useCallback(() => {
    doSomething(a, b);
  },[a, b]);
  • doSomething() 함수는 a 또는 b의 값이 변경되는 경우 다음에 다시 렌더링할 때만 다시 호출된다.
  • useCallback(fn, [])은 useMemo(() => fn, [])와 동일

실습

useCallback과 참조 동등성

  • useCallback은 참조 동등성에 의존한다.

  • React는 JS언어로 만들어진 오픈소스 라이브러리이기 때문에 기본적으로 JS문법을 따른다. JS에서 함수는 객체다.

    • 객체는 값의 주소를 저장하기 때문에 반환하는 값이 같아도 일치연산자로 비교하면 false가 출력된다.

      function doubleFactory(){
       return (a) => 2 * a;
      }
      const double1 = doubleFactory();
      const double2 = doubleFactory();
      
      double1(8); // 16
      double2(8); // 16
      
      double1 === double2;  // false
      double1 === double1;  // true
    • double1double2는 같은 함수를 할당했음에도 메모리 주소 값이 다르기 때문에, 메모리 주소에 의한 참조 비교 시 다른 함수로 봄

  • React도 이와 같다. React는 리렌더링 시 함수를 새로이 만들어서 호출을 한다. (새롭게 만들어 호출된 함수 ≠ 기존 함수)

    • 그러나 useCallback을 이용해 함수 자체를 저장해서 재사용하면, 함수 메모리 주소 값을 저장했다가 재사용하는 것과 같다. 재선언을 막는다.
    • 따라서 React 컴포넌트 함수 내에서 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길때 예상치 못한 성능문제를 막을 수 있다.


📌 Custom Hooks

개발자가 스스로 커스텀한 훅
이를 이용해 반복되는 로직을 함수로 뽑아내어 재사용할 수 있다.

장점

  1. 상태관리 로직의 재활용이 가능하고
  2. 클래스 컴포넌트보다 적은 양의 코드로 동일한 로직을 구현할 수 있으며
  3. 함수형으로 작성하기 때문에 보다 명료하다. (e.g. useSomething)

Custom Hook을 정의할 때 규칙

  • 함수 이름 앞에 use 붙이기
  • 대부분 프로젝트 내의 hooks 디렉토리에 Custom Hook을 위치 시킨다.
  • Custom Hook으로 만들 때 함수는 조건부 함수가 아니어야 한다.
    즉 return 하는 값은 조건부여서는 안 된다.

⭐️ 같은 Custom Hook 사용 ≠ 같은 state 공유
⭐️ 그저 로직만 공유할 뿐
⭐️ state는 컴포넌트 내에서 독립적으로 정의되어 있다.

Custom Hook 예시

import { useState, useCallback } from 'react';

function useInputs(initialForm) {
  const [form, setForm] = useState(initialForm);
  // change
  const onChange = useCallback(e => {
    const { name, value } = e.target;
    setForm(form => ({ ...form, [name]: value }));
  }, []);
  const reset = useCallback(() => setForm(initialForm), [initialForm]);
  return [form, onChange, reset];
}

export default useInputs;
// [코드] 여러 input에 의한 상태 변경을 할 때 쓸 수 있는 useInputs Hooks


Chapter3. React 기능

React 버전 18로 업데이트 되면서...

  • ReactDOM.render 지원 x
const rootElement = document.getElementById("root");
ReactDOM.render(<AppTest />, rootElement);
// React 18이전의 index.js
  • 이제 createRoot API 사용
import { createRoot } from "react-dom/client";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
    <App />
);
// 바뀐 index.js

📌 코드 분할(Code Spliting)

“어느 페이지에서 코드를 해석하고 실행하는 정도가 느려졌는지 파악해서 번들을 나눈 뒤에 지금 필요한 코드만 불러오고 나중에 필요한 코드는 나중에 불러올 수 있지 않을까??”

코드 분할 : 런타임 시 여러 번들을 동적으로 만들고 불러오는 것으로, Webpack, Rollup과 같은 번들러가 지원하는 기능.

  • 번들이 거대해지는 것을 방지하기 위한 좋은 해결 방법 : 번들을 물리적으로 나누는 것
    • 지금 필요한 코드가 아니라면 따로 분리를 시키고, 나중에 필요할 때 불러와서 사용할 수 있다.
    • 페이지의 로딩 속도를 개선할 수 있다는 장점

번들 분할 & 줄이는 법

  • 번들링 되는 파일에는 npm을 통해 다운 받는 서드파티(Third Party) 라이브러리도 포함된다.
    • 서드파티(Third Party) 라이브러리 : 제 3자 라이브러리
      • 장점 : 플러그인이나 라이브러리 & 프레임워크 등이 존재하며 이 라이브러리를 잘 사용하면 편하고 효율적인 개발 가능
      • 단점
        • 사용자에게 다양한 메소드를 제공하기 때문에 코드의 양이 많다.
        • 번들링 시 많은 공간을 차지한다.
  • 해결방안
    • 사용 중인 라이브러리 전부를 불러오지말고, 따로 불러와서 사용하여 공간을 확보한다.
/* lodash 라이브러리 전체를 불러와서 그 안에 들은 메소드를 꺼내 쓰는 것은 비효율적.*/
import _ from 'lodash';
...
_.find([]);

/* 이렇게 lodash의 메소드 중 하나를 불러와 쓰는 것이 앱의 성능에 더 좋다.*/
import find from 'lodash/find';
find([]);

React에서의 코드 분할

  • React가 코드 분할 개념 도입한 이유
    • React는 SPA(Single-Page-Application)이므로 사용하지 않는 컴포넌트까지 한번에 불러와서 첫 화면이 렌더링 되는 시간이 오래걸린다.
    • 그래서 사용하지 않는 컴포넌트는 나중에 불러오고자 도입했다.
  • React에서 코드 분할 하는 방법
    • dynamic import(동적 불러오기)사용
    • static import(정적 불러오기) :import지시자를 사용해 파일을 불러오는 방법 (기존 방법)

Static Import(정적 불러오기, 기존방식)

/* 최상위 import 지시자로 라이브러리 및 파일을 불러오고 */
import moduleA from "library";

form.addEventListener("submit", e => {
  e.preventDefault();
  someFunction();
});

const someFunction = () => {
  /* 코드 중간에서 불러온 파일을 사용. */
}

🌱 Dynamic Import(동적 불러오기)

form.addEventListener("submit", e => {
  e.preventDefault();
	/* 동적 불러오기는 이런 식으로 코드의 중간에 불러올 수 있게 됨. */
  import('library.moduleA')
    .then(module => module.default)
    .then(someFunction())
    .catch(handleError());
});

const someFunction = () => {
    /* moduleA를 여기서 사용. */
}
  • then 함수를 사용해 필요한 코드만 가져온다.
  • 가져온 코드에 대한 모든 호출은 해당 함수 내부에 있어야 한다.
    • 이 방식을 사용하면 번들링 시 분할된 코드(청크)를 지연 로딩시키거나 요청 시 로딩할 수 있다.
  • React.lazy와 함께 사용할 수 있다.

📌 React.lazy()와 Suspense

🌱 React.lazy()

React.lazy를 통해 컴포넌트를 동적으로 import할 수 있다.

  • 이를 사용하면 초기 렌더링 지연시간을 어느정도 줄일 수 있게 된다
  • Router로 분기가 나눠진 컴포넌트들을 lazy를 통해 import하게 되면 해당 path로 이동할때 컴포넌트를 불러오게 된다.
  • React.lazy로 감싼 컴포넌트는 단독 사용 X
  • React.suspense 컴포넌트 하위에서 렌더링 해야함
import Component from './Component';

/* React.lazy로 dynamic import를 감싸준다. */
const Component = React.lazy(() => import('./Component'));

🌱 React.Suspense

렌더링이 준비되지 않은 컴포넌트가 있을 때 로딩 화면을 보여주고 로딩이 완료되면 렌더링이 준비된 컴포넌트를 보여주는 기능

/* suspense 기능을 사용하기 위해서는 import 해와야 함 */
import { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

function MyComponent() {
  return (
    <div>
			{/* 이런 식으로 React.lazy로 감싼 컴포넌트를 Suspense 컴포넌트의 하위에 렌더링한다. */}
      <Suspense fallback={<div>Loading...</div>}>
				{/* Suspense 컴포넌트 하위에 여러 개의 lazy 컴포넌트를 렌더링시킬 수 있다. */}
        <OtherComponent />
				<AnotherComponent />
      </Suspense>
    </div>
  );
}
  • Supense 컴포넌트의 fallback prop은 컴포넌트가 로드될 때까지 기다리는 동안 로딩 화면으로 보여줄 React 엘리먼트를 받아들임
  • Suspense 컴포넌트 하나로 여러 개의 lazy 컴포넌트를 보여줄 수도 있다.

🌱 React.lazy와 Suspense의 적용

  • 웹 페이지를 불러오고 진입하는 단계인 Route에 두 기능을 적용시키는 게 좋다.
import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

// 1. 라우터가 분기되는 컴포넌트에서 각 컴포넌트에 React.lazy를 사용하여 import
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
  //  Route 컴포넌트들을 Suspense로 감싼 후 로딩 화면으로 사용할 컴포넌트를 fallback 속성으로 설정
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);
  • 초기 렌더링 시간이 줄어든다.
  • 페이지를 이동하는 과정마다 로딩 화면이 보여진다.
profile
블로그 이사했습니다 🧳

0개의 댓글