항해플러스 프론트엔드 5기 후기(3주차) - 프레임워크 없이 SPA 만들기(렌더링 최적화)

유한별·2025년 4월 13일
5
post-thumbnail

이번 과제는 리액트를 깊이 이해하고 실제로 잘 활용하기 위해 필요한 여러 고민들을 했던 시간이었다.
이전 과제에서 Virtual DOM을 직접 구현하면서 리액트의 렌더링 구조와 이벤트 위임, diffing 알고리즘의 기초 원리를 배웠다면, 이번 과제에서는 리액트 앱의 성능 최적화와 구조 설계, 렌더링 제어에 집중했다.

단순히 기능을 구현하는 것에서 벗어나, Context 분리, memoization, 렌더링 트래킹, 컴포넌트 리렌더링 최적화까지, 실제 규모가 있는 리액트 앱을 운영할 때의 고민들을 작게나마 체험할 수 있었다.
이 과정에서 얻은 여러 교훈들을 이번 글에서 정리해보고자 한다.

🛠️ 커스텀 훅과 렌더링 제어 도구 구현

리액트에서 성능을 고려한 렌더링 제어는 useMemo, useCallback, memo 등 다양한 도구를 통해 이루어진다.
이번 과제에서는 이 모든 도구를 직접 구현하며, 내부 원리와 동작 방식에 대한 깊은 이해를 쌓는 경험을 했다.

비교 함수 구현

자바스크립트에서 객체와 배열은 모두 참조 타입으로, 같은 참조를 가진다면 내부 구조 또한 같다고 볼 수 있다.
하지만 두 값이 서로 다른 참조를 갖는 경우에는 비교가 필요하다.
이 비교는 목적에 맞게 얕은 비교(shallowEqual)깊은 비교(deepEqual)로 나눠서 할 수 있는데, 각 방법에 대해 어떻게 적용했는지 적어보았다.

shallowEquals는 성능을 고려해 객체의 key 수가 같고, 각 key의 값이 Object.is를 기준으로 동일한지만 확인하도록 구현했다.
이 과정에서 NaN 비교와 0-0 문제를 안전하게 처리하기 위해 ===이 아닌 Object.is를 사용해야 했다. Object.isNaNNaN과 비교할 때 true를 반환하고, -0+0을 구별할 수 있어 정확한 비교를 보장하는 데 필수적이었다.

또한 객체 여부를 판별하는 과정에서 isObject 헬퍼 함수를 함께 활용했다. 이 함수는 typeof === "object"만으로는 null이 포함될 수 있는 문제를 방지하기 위해 value !== null 추가 조건을 포함시켰다. 이로 인해 null이나 undefined 같은 값들이 비교 과정에서 누락되지 않도록 안전하게 다룰 수 있었다.

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function haveSameKeys(a: object, b: object): boolean {
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  return keysA.length === keysB.length;
}

function areShallowValuesEqual(
  a: Record<string, unknown>,
  b: Record<string, unknown>
): boolean {
  return Object.keys(a).every((key) => key in b && Object.is(a[key], b[key]));
}

export function shallowEquals(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;

  if (!isObject(a) || !isObject(b)) return false;

  if (!haveSameKeys(a, b)) return false;

  return areShallowValuesEqual(a, b);
}

반면 deepEquals는 구조적으로 훨씬 더 복잡했다. 배열과 객체를 구분하여 재귀적으로 순회하면서 모든 요소 또는 속성 값을 비교해야 했고, 순서까지 고려해야 하므로 반복 구조가 필수적이었다. 여기서도 타입 안정성을 고려하여, 각 비교 항목에 대해 타입을 확실히 정의하고 예외 처리를 추가하는 방식으로 진행했다.

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function areArraysEqual(a: unknown[], b: unknown[]): boolean {
  if (a.length !== b.length) return false;
  return a.every((item, index) => deepEquals(item, b[index]));
}

function areObjectsEqual(
  a: Record<string, unknown>,
  b: Record<string, unknown>
): boolean {
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every((key) => key in b && deepEquals(a[key], b[key]));
}

export function deepEquals(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;

  if (Array.isArray(a) && Array.isArray(b)) {
    return areArraysEqual(a, b);
  }

  if (isObject(a) && isObject(b)) {
    return areObjectsEqual(a, b);
  }

  return false;
}

깊은 비교에서 자주 발생할 수 있는 예외를 방지하기 위해 typeof와 같은 안전한 타입 체크를 사용하여 정확한 타입만 비교하도록 했고, 이를 통해 발생할 수 있는 오류를 미리 방지했다.

비교 함수는 단순히 동작만 하면 되는 도구가 아니라, useMemo, useCallback, memo와 같은 리액트 최적화 도구와 밀접하게 연결되어 있기 때문에, 타입 안전성은 성능 최적화와 함께 중요한 역할을 한다. 특히 deepEquals를 잘못 사용할 경우 렌더링 최적화에 부정적인 영향을 미칠 수 있다.

useRef 구현

useRef를 직접 구현하면서 가장 중요한 점은 렌더링 사이에서도 값이 초기화되지 않고 유지되어야 한다는 것이다. 처음에는 const obj = { current: null }처럼 객체를 만들어 저장하면 될 것 같았지만, 함수 컴포넌트는 매 렌더링마다 다시 실행되기 때문에 매번 새로 만들어져 초기화되는 구조가 된다. 이는 useRef의 핵심 기능과 맞지 않기 때문에, 다른 방법을 고민해야 했다.

이를 해결하기 위해, 내부적으로 상태를 보존할 수 있는 저장소를 useState로 만들어 state.current로 접근 가능하게 설계했다. 또한 React의 관용 스타일에 따라 기본값을 null이 아닌 undefined로 설정해 일관성을 유지했다.

import { useState } from "react";

export function useRef<T>(initialValue?: T | null): { current: T | null } {
  const [ref] = useState({ current: initialValue ?? null });
  return ref;
}

useMemo 구현

useMemo는 의존성 배열이 변경되지 않으면 계산 결과를 재사용하는 원리로 작동한다. 이를 구현할 때 useRef를 활용해 이전 결과와 의존성 배열을 저장하고, shallowEquals를 이용해 비교하여 재계산 여부를 결정하는 방식으로 설계했다.

import { DependencyList } from "react";
import { shallowEquals } from "../../equalities";
import { useRef } from "./useRef";

export function useMemo<T>(
  factory: () => T,
  _deps: DependencyList,
  _equals = shallowEquals
): T {
  const valueRef = useRef<T>(null);
  const depsRef = useRef<DependencyList>(null);

  if (!depsRef.current || !_equals(_deps, depsRef.current)) {
    valueRef.current = factory();
    depsRef.current = _deps;
  }

  return valueRef.current as T;
}

특히 의존성 배열을 다룰 때마다 리액트의 "불변성 유지" 원칙이 얼마나 중요한지 실감할 수 있었다. useMemo를 사용할 때 가장 중요한 점은 계산된 결과를 반환하는 것이 아니라, 언제 재계산을 해야 하는지를 정확히 판단하는 것이다.

의존성 배열을 제대로 관리하지 않으면 불필요한 재계산이 발생하고, 그로 인해 성능 저하가 초래될 수 있다는 점을 다시 한번 깨달았다.

useCallback

useCallbackuseMemo와 비슷하지만, 주로 함수를 메모이제이션할 때 사용된다. 이 훅은 의존성 배열이 변경되지 않는 한, 동일한 함수 인스턴스를 반환하게 해준다.

처음에는 useMemo처럼 단순히 결과값을 메모이제이션하는 것처럼 느껴졌지만, useCallback이 주로 함수에 적용되는 이유는 함수 인스턴스를 메모이제이션하는 것이 중요하기 때문이다.

특히 컴포넌트에 props로 전달되는 함수는 변경되지 않더라도, 렌더링될 때마다 새로운 함수 인스턴스가 생성되면 memo로 감싼 자식 컴포넌트들이 불필요하게 리렌더링될 수 있다. 이 문제를 해결하기 위해 useCallback을 사용해 함수를 메모이제이션함으로써, 리렌더링을 최적화할 수 있다.

/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { DependencyList } from "react";
import { useMemo } from "./useMemo";

export function useCallback<T extends Function>(
  factory: T,
  _deps: DependencyList
) {
  const fn = useMemo(() => factory, _deps);
  return fn as T;
}

useMemouseCallback의 가장 큰 차이점은 의존성 배열을 어떻게 다루느냐에 따라 성능 최적화의 효과가 달라진다는 점이다. 함수 인스턴스와 계산된 값이 언제 변경되는지 정확히 판단하는 것은 불필요한 리렌더링을 방지하는 데 매우 중요하다.

고차 컴포넌트(HOC) 구현

memo는 리액트에서 동일한 props가 들어올 경우 컴포넌트의 리렌더링을 건너뛰게 해주는 고차 컴포넌트(HOC)다.

이번 과제에서는 이를 직접 구현하면서 HOC의 작동 방식과 내부 흐름을 더 구체적으로 이해할 수 있었다. 핵심은 이전 props와 현재 props를 비교해 같을 경우 컴포넌트를 다시 호출하지 않고 이전 결과를 그대로 반환하는 구조였다.

이를 구현하기 위해 useRef를 활용해 이전 props와 컴포넌트 실행 결과를 저장하고, 매 렌더링마다 shallowEquals로 비교한 뒤 필요 시에만 컴포넌트를 다시 실행하도록 했다. 이렇게 함으로써 불필요한 리렌더링을 막고 성능을 최적화할 수 있었다.

import { shallowEquals } from "../equalities";
import { ComponentType } from "react";
import { useRef } from "../hooks";
import React from "react";

export function memo<P extends object>(
  Component: ComponentType<P>,
  _equals = shallowEquals
) {
  return function MemoizedComponent(props: P): JSX.Element {
    const prevPropsRef = useRef<P>(null);
    const prevResultRef = useRef<JSX.Element>(null);

    if (
      prevPropsRef.current !== undefined &&
      _equals(prevPropsRef.current, props)
    ) {
      return prevResultRef.current!;
    }

    const result = React.createElement(Component, props);
    prevPropsRef.current = props;
    prevResultRef.current = result;

    return result;
  };
}

🧩 Context 분리와 Provider 설계

하나의 Context에서 역할 분리하기

처음에는 하나의 AppContext에서 모든 상태를 관리하고 있었지만, 실제 컴포넌트들이 필요로 하는 데이터가 각기 다르기 때문에 기능별로 Context를 분리하는 것이 더 나은 설계라는 판단을 내렸다.

이에 따라 Theme, User, Notification 각각의 상태와 액션을 독립된 Context로 분리하고, 필요한 범위에서만 Provider를 적용하는 방식으로 구조를 재설계했다.
또한 Context 내부의 값을 stateactions로 나누어 관리함으로써, 실제 컴포넌트에서 필요한 데이터만 구독하게 해 불필요한 리렌더링을 줄이는 데 집중했다.

예를 들어, 테마 정보를 사용하는 컴포넌트는 테마 값만 구독하고, 토글 함수를 사용하는 쪽에서는 액션만 가져가도록 설계했다.

const ThemeStateContext = createContext<"light" | "dark">("light");
const ThemeActionContext = createContext<() => void>(() => {});

export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const toggleTheme = useCallback(() => {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  }, []);

  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeActionContext.Provider value={toggleTheme}>
        {children}
      </ThemeActionContext.Provider>
    </ThemeStateContext.Provider>
  );
};
// src/components/Header/ThemeToggle.tsx
export const ThemeToggle = memo(() => {
  const theme = useThemeState();
  const toggleTheme = useThemeAction();

  return (
    <Button onClick={toggleTheme} className="mr-2">
      {theme === "light" ? "다크 모드" : "라이트 모드"}
    </Button>
  );
});
// src/components/layout/MainLayout.tsx
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
  const theme = useThemeState();

  return (
    <div
      className={`min-h-screen ${theme === "light" ? "bg-gray-100" : "bg-gray-900 text-white"}`}
    >
	...
    </div>
  );
};

이렇게 관심사에 따라 Context를 분리하고 역할 기반으로 Provider를 구성하면서, 코드의 응집도를 높이고 컴포넌트 간 결합도를 줄일 수 있었다.

구조는 최종적으로 Theme, User, Notification 각각의 Provider를 최상단에서 한 번에 감싸는 구조를 선택했다. 이렇게 하면 각 컴포넌트들이 필요한 Context에 자유롭게 접근할 수 있고, Provider 중첩 구조를 신경쓰지 않아도 된다.

const App: React.FC = () => {
  return (
    <ThemeProvider>
      <NotificationProvider>
        <UserProvider>
          <HomePage />
        </UserProvider>
      </NotificationProvider>
    </ThemeProvider>
  );
};

물론 일부 Context만 필요한 컴포넌트가 존재할 때는 하위 범위에서 Provider를 감싸는 방식이 더 적절할 수도 있겠지만, 현재 과제의 구조와 요구사항을 고려했을 때는 최상단에서 한 번에 감싸는 것이 가장 단순하고 효율적인 방법이라고 판단해 적용했다.

🚀 렌더링 최적화와 성능 개선

ComplexForm의 상태 관리

ComplexForm은 여러 입력 필드를 포함한 폼 컴포넌트로, 처음에는 useState를 사용해 전체 폼 데이터를 관리했다. 하지만 이 방식에는 문제가 있었다. 각 입력 필드가 변경될 때마다 전체 폼이 리렌더링되면서 하위 컴포넌트인 InputField까지 모두 리렌더링되는 비효율적인 구조가 발생했다. 이런 구조에서는 불필요한 리렌더링을 방지하기 어려운 상황이 발생했다.

이 문제를 해결하기 위해 useReducer를 도입했다. useReducer는 상태 업데이트를 명확하게 처리하고, 액션을 통해 상태 변경을 분리하는 장점이 있다. 이를 통해 상태 변경이 각 입력 필드에 독립적으로 이루어져 불필요한 리렌더링을 줄일 수 있을 것이라고 생각했다.

// formReducer.ts
export function formReducer(state: FormState, action: Action): FormState {
  switch (action.type) {
    case "UPDATE_FIELD":
      return {
        ...state,
        [action.name]:
          action.name === "age" ? Number(action.value) || 0 : action.value,
      };
    case "TOGGLE_PREFERENCE":
      return {
        ...state,
        preferences: state.preferences.includes(action.preference)
          ? state.preferences.filter((p) => p !== action.preference)
          : [...state.preferences, action.preference],
      };
    default:
      return state;
  }
}

하지만 formData 객체 전체가 갱신되는 구조에서는 여전히 상위 컴포넌트인 ComplexForm이 리렌더링되고, 하위 컴포넌트들도 리렌더링되는 문제가 해결되지 않았다. 결국 상태 관리 방법에 있어 더 깊은 고민이 필요함을 알게 되었다.

비제어 컴포넌트(uncontrolled input)로 전환하여 리렌더링 최적화를 시도하기도 했다. 비제어 컴포넌트는 값이 변경될 때마다 리렌더링하지 않기 때문에 최적화에 유리할 수 있지만, 과제의 테스트 조건을 만족시키지 못했다. 테스트에서는 "폼 입력 시 ComplexForm만 리렌더링되어야 한다"는 조건이 있었고, 비제어 컴포넌트는 ComplexForm이 리렌더링되지 않아 이 조건을 충족할 수 없었다.

결국 이 구조에서는 formData가 하나의 객체로 묶여 있는 한, 입력 하나가 바뀔 때마다 전체 폼이 리렌더링되는 문제를 완전히 피할 수 없었다. useReducer는 상태 관리의 명확성을 높여주었지만, 테스트 조건을 충족시키는 최적화를 이루지는 못했다.

이는 단순한 훅 변경이나 memo 적용만으로 해결될 수 있는 문제가 아니라, 컴포넌트 간 상태 구조와 렌더링 책임을 처음부터 어떻게 설계하느냐에 대한 문제라는 점을 다시금 깨닫게 해줬다.

Virtual List를 활용한 대규모 렌더링 최적화

렌더링 최적화는 과제 내내 중요한 고민거리였고, memo, useMemo, useCallback과 같은 도구들을 활용하여 불필요한 렌더링을 방지하고 성능을 개선하는 데 집중했다.

처음에는 1000개의 아이템을 기준으로 최적화를 적용했지만, 아이템의 수가 늘어나면서 성능에 한계가 보이기 시작했다. 그래서 아이템 수를 10000개로 늘려가며 실제로 성능 차이를 분석하고, 최적화가 얼마나 효과적인지 확인했다.

React Profiler를 활용해 렌더링 성능을 분석한 결과, 아이템의 수가 많아질수록 렌더링 시간이 급격히 증가하는 문제를 확인할 수 있었다. 이를 해결하기 위해 filter === '' 일 때 연산을 하지 않는 방법을 시도했지만, 이 방법은 근본적인 성능 향상에는 한계가 있었다.

filter 적용 전

filter 적용 후

성능을 개선하기 위한 더 효과적인 방법으로 virtual list 방식을 적용해보기로 했다. virtual list는 화면에 보이는 아이템만 렌더링하여 성능을 크게 향상시킬 수 있었고, 몇 만개씩 아이템을 렌더링해도 렌더링 시간이 현저히 단축되었다.

Virtual List 적용 전 10000개 렌더링 시간 (기존 로직)

  • 10000개만 추가해도 거의 1초(1000ms)의 시간이 필요

Virtual List 적용 후 렌더링 시간

  • 10000개씩 총 5번을 추가해도 각 렌더링 시간은 매우 적게 소요
  • 실제로 렌더링되는 것이 아니기 때문에 최초 2~30개의 리스트만 렌더링하는데 시간 소요
// VisibleItemList.tsx

import { memo } from "../../hocs";
import { Item } from "../../types";
import { ItemRow } from "./ItemRow";

interface Props {
  getItem: (index: number) => Item;
  itemCount: number;
  theme: "light" | "dark";
  startIndex: number;
  endIndex: number;
  itemHeight: number;
}

export const VisibleItemList: React.FC<Props> = memo(
  ({ getItem, itemCount, theme, startIndex, endIndex, itemHeight }) => {
    const visibleCount = endIndex - startIndex;
    return (
      <>
        {Array.from({ length: visibleCount }).map((_, i) => {
          const actualIndex = startIndex + i;
          const item = getItem(actualIndex);
          if (!item) return null;
          return (
            <div
              key={actualIndex}
              style={{
                position: "absolute",
                top: actualIndex * itemHeight,
                left: 0,
                right: 0,
                height: itemHeight,
              }}
            >
              <ItemRow item={item} theme={theme} />
            </div>
          );
        })}
      </>
    );
  }
);
// ItemList.tsx

const ITEM_HEIGHT = 40;

export const ItemList: React.FC<{
  items: Item[];
  onAddItemsClick: () => void;
}> = memo(({ items, onAddItemsClick }) => {
  ...
  const listRef = useRef<HTMLDivElement>(null);

  const { startIndex, endIndex } = useVirtualScroll({
    containerRef: listRef,
    itemHeight: ITEM_HEIGHT,
    itemCount: filteredItems.length,
  });

  return (
    <div className="mt-8">
      ...
      <div
        ref={listRef}
        style={{
          position: "relative",
          height: filteredItems.length * ITEM_HEIGHT,
        }}
      >
        <VisibleItemList
          getItem={(index) => filteredItems[index]}
          itemCount={filteredItems.length}
          theme={theme}
          startIndex={startIndex}
          endIndex={endIndex}
          itemHeight={ITEM_HEIGHT}
        />
      </div>
    </div>
  );
});
// 미사용 코드

import { useEffect, useState } from "react";

interface UseVirtualScrollProps {
  containerRef: React.RefObject<HTMLElement>;
  itemHeight: number;
  itemCount: number;
  overscan?: number;
}

export function useVirtualScroll({
  containerRef,
  itemHeight,
  itemCount,
  overscan = 10,
}: UseVirtualScrollProps) {
  const [startIndex, setStartIndex] = useState(0);
  const [endIndex, setEndIndex] = useState(20);

  const requestIdleCallback =
    window.requestIdleCallback ||
    function (cb: () => void) {
      return setTimeout(cb, 1);
    };

  const cancelIdleCallback =
    window.cancelIdleCallback ||
    function (id: number) {
      clearTimeout(id);
    };

  useEffect(() => {
    const onScroll = () => {
      if (!containerRef.current) return;

      const rect = containerRef.current.getBoundingClientRect();
      const offsetTop = rect.top + window.scrollY;
      const scrollTop = window.scrollY;
      const viewportHeight = window.innerHeight;

      const visibleStart = Math.floor((scrollTop - offsetTop) / itemHeight);
      const visibleEnd = Math.ceil(
        (scrollTop - offsetTop + viewportHeight) / itemHeight
      );

      setStartIndex(Math.max(0, visibleStart - overscan));
      setEndIndex(Math.min(itemCount, visibleEnd + overscan));
    };

    const idleId = requestIdleCallback(() => {
      onScroll();
    });

    window.addEventListener("scroll", onScroll);
    window.addEventListener("resize", onScroll);

    return () => {
      cancelIdleCallback(idleId);
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [containerRef, itemHeight, itemCount, overscan]);

  const visibleItems = Array(endIndex - startIndex).fill(null);

  return {
    startIndex,
    endIndex,
    visibleItems,
  };
}

하지만 virtual list는 테스트 요구 사항을 충족시키지 못해 최종적으로 적용하지 못했다. 테스트에서는 ItemList가 "단 한 번만 렌더링"되어야 한다는 조건이 있었고, virtual list에서 보여야 할 아이템 개수가 브라우저 크기에 따라 동적으로 변하는 과정에서 ItemList가 재렌더링되는 문제가 발생했다. 이로 인해 성능 최적화를 위해 적용한 virtual list 방식이 테스트 조건과 충돌하여 구현하지 못한 점이 아쉬웠다.

이번 과정을 통해 성능 최적화를 위한 여러 접근 방식의 효과를 직접 체감할 수 있었고, 실제 프로젝트에서 발생할 수 있는 성능 문제를 어떻게 해결할지에 대해 고민할 기회가 되었다.

✍️ 회고

🧠 느낀 점

이번 과제를 통해 리액트 성능 최적화와 구조 설계에 대한 깊은 이해를 얻을 수 있었다. useMemo, useCallback, memo 등 다양한 도구들을 직접 구현하고 적용하며 컴포넌트 리렌더링을 제어하고 최적화하는 방식에 대한 감각이 생겼다. 또한, virtual list를 도입해 대규모 렌더링을 최적화하는 과정에서 성능 향상의 효과를 경험할 수 있었다.

특히 Context 분리와 Provider 설계는 리액트 앱을 효율적으로 관리하기 위한 중요한 기초라는 걸 깨달았다. 최적화와 상태 관리 방식에 대한 고민은 과제를 진행하면서 끊임없이 이어졌고, 결국 이런 고민들이 실제로 더 큰 규모의 프로젝트에서 어떻게 적용될 수 있을지를 구체적으로 생각해볼 수 있는 기회가 됐다.

🤔 향후 개선 방향

비록 virtual list와 같은 일부 최적화 방법은 테스트 조건을 충족하지 못해 프로젝트에 직접 적용하지는 못했지만, 성능 최적화의 중요성과 다양한 방법들을 직접 적용해보면서 많은 교훈을 얻었다. 앞으로는 이런 최적화 기법들이 실제 프로젝트에서 어떻게 더 유연하게 적용될 수 있을지, 그리고 테스트와 실제 성능 개선을 어떻게 일치시킬 수 있을지 고민해볼 필요가 있다.

이번 과제를 통해 성능 최적화와 리액트의 구조적 설계에 대한 중요한 교훈을 얻었고, 앞으로 더 큰 프로젝트에서 이러한 경험을 어떻게 적용할 수 있을지 고민해볼 기회가 되었다.

과제 결과 및 코드

profile
세상에 못할 일은 없어!

1개의 댓글

comment-user-thumbnail
2025년 4월 14일

역시 멋있는 사람

답글 달기