[TOJ] 개발기록 - 페이지네이션 UI와 기능 직접 구현해보기

조민호·2023년 11월 9일
0
post-thumbnail
post-custom-banner

제출 현황 페이지 컴포넌트이다.

useGetSubmitListuseGetSubmitSize 2개의 useQuery 커스텀 훅을

통해서 현재 페이지에 해당하는 제출 문제 리스트들을 반환해준다.

기존의 로직을 통해서 구현된 페이지네이션 상황은 아래와 같다

기능 자체는 제대로 동작을 하지만 당연히 이런 디자인 그대로 사용할 수는 없다.

처음에는 라이브러리를 사용하려고 했다. 그렇지만 매번 페이지네이션을 사용할 때마다 라이브러리를 사용할 것도 아니고 시간이 상대적으로 여유로운 토이프로젝트인 만큼, 라이브러리 없이 만들어 보는 것도 좋은 경험이 될 것 같아서 처음부터 끝까지 직접 만들어보기로 했다.




결과적으로 내가 구현한 디자과 기능은 아래와 같다

  • 버튼 디자인, hover에 따른 반응, 현재 페이지 표시
  • 양 옆으로 이동하는 화살표 버튼
  • 양 끝으로 이동하는 이중화살표 버튼
  • 잔여 페이지 … 으로 표시

구현 진행하기

페이지네이션 버튼들을 보관하고 있는 PaginationButtons 컴포넌트를

추가로 생성한다

해당 컴포넌트에 props로 넘겨주는 요소는 아래와 같다

  • (마지막 페이지 번호를 파악하기 위한) 전체 사이즈
  • 현재 보고 있는 페이지 번호 상태값
  • 현재 보고 있는 페이지 번호 상태값 업데이트 함수

하나씩 구현을 해 보자면

0. 한번에 보여줄 버튼들의 갯수를 정하는 상수를 선언한다

export const MAXIMUM_PAGE_BUTTON_COUNT = 4;

앞으로 한번에 최대 4개의 버튼을 보여주는 것이다

1. (시작은 1페이지로 고정이니까)마지막 페이지 번호를 생성

const LAST_PAGE_NUMBER = Math.ceil(totalSize / COUNT_PER_PAGE);

여기서 페이지 번호 단위는 배열 인덱스가 아닌, 실제 페이지 번호 단위이다

EX) 3페이지 : LAST_PAGE_NUMBER=2 (x) , LAST_PAGE_NUMBER=3 (o)


2. 페이지네이션에서 한번에 보여줄 버튼의 갯수만큼 slice해주는 인덱스를 생성한다

예를 들어 페이지 버튼이 1~10까지 있다.

여기서 4개의 버튼씩 보여주려면( MAXIMUM_PAGE_BUTTON_COUNT=4 )

1234 , 5678 , 910 형태로 보여줘야 한다.


이처럼 여러개의 버튼들이 있을 때, 이 버튼들을 최초 상황을 기준으로 (시작페이지는 1로 고정),

MAXIMUM_PAGE_BUTTON_COUNT갯수에 맞게

1부터 슬라이싱해서 마지막 인덱스를 반환하는 유틸함수를 생성한다

// ex) 페이지 10개 >> 슬라이싱 시작인덱스 1(고정), 끝 인덱스 4+1
// >>  1,2,3,4 버튼 생성

import { MAXIMUM_PAGE_BUTTON_COUNT } from '@/config/const';

export const getLastPageSlicedIndexAtFirst = (lastPageNumber: number): number => {
  if (lastPageNumber < MAXIMUM_PAGE_BUTTON_COUNT) {
    return lastPageNumber + 1;
  } else {
    return MAXIMUM_PAGE_BUTTON_COUNT + 1;
  }
};

저기서 +1을 한 이유는 slice에 사용되는 인덱스이므로 끝 인덱스는 +1을 해야하기 때문이다

5페이지 ~ 9페이지
>> slicedPageInex : [5,10]


추가적으로, 만약 4개씩 보여주는 상황에서 전체 페이지의 갯수가

3개밖에 없을 때는 3페이지까지만 보여줘야 하므로 조건(lastPageNumber < MAXIMUM_PAGE_BUTTON_COUNT)을 걸어줘야 한다.

const [slicedPageInex, setSlicedPageInex] = useState([
    1,
    getLastPageSlicedIndexAtFirst(LAST_PAGE_NUMBER),
  ]);

slicedPageInex 상태값은 현재 존재하는 제출 리스트 기반으로

여러개의 페이지와 버튼들이 만들어졌을 때, 이 버튼들을 MAXIMUM_PAGE_BUTTON_COUNT로 지정한 4개씩 slice해주는 인덱스들이다

다만 최초 페이지 진입 시에는 1페이지부터 보여줘야 하므로 초기값은 1과 getLastPageSlicedIndexAtFirst()로 지정한다

3. 버튼들을 생성한다

                  **// (전체 리스트갯수 / 한 페이지마다 보여줄 갯수)**
const LAST_PAGE_NUMBER = Math.ceil(totalSize / COUNT_PER_PAGE);

...

{new Array(LAST_PAGE_NUMBER + 1)
        .fill(0)
        .map((_, arrIndex: number) => arrIndex)
        .slice(slicedPageInex[0], slicedPageInex[1])
        .map((pageNumber, pageIndex) => {
          return (
            <button
              key={pageIndex}
              type="button"
              className={`${ButtonsStyle} ${currentPage === pageNumber && 'current'}`}
              onClick={() => {
                setCurrentPage(pageNumber);
              }}
            >
              {pageNumber}
            </button>
          );
})}

LAST_PAGE_NUMBER 는 현재 제출 리스트의 갯수, 한 페이지마다 보여줄 리스트의 갯수를 기반으로 마지막 페이지 번호를 구한 값이다

그리고 나서 마지막 페이지의 번호+1 만큼의 크기를 가진 배열을 생성한다. (마지막 페이지 번호가 4라면 5개의 배열을 생성하는 것이다)

이제 이 배열의 크기만큼 페이지네이션의 버튼들의 갯수가 생성된다.

배열의 크기를 LAST_PAGE_NUMBER 에서 +1을 한 이유는 앞으로 모든 버튼의 인덱스를 실제 페이지의 번호와 동일하게 사용하기 위함이다.

그래서 slicedPageInex의 초깃값은 1페이지를 의미해야 하므로

0이 아닌 1로 사용한 것이다.

4. 버튼들의 스타일을 적용한다

현재 vanilla-extract 를 사용하고 있으므로 동적 className을 생성한다

버튼 디자인, hover에 따른 반응, 현재 페이지를 표시 해주는 디자인을 적용한다

export const ButtonsStyle = style({
  display: 'flex',
  alignItems: 'center',
  fontSize: '1.1rem',
  padding: '2px 15px',
  margin: '15px',
  border: 'none',
  borderRadius: '20px',
  backgroundColor: 'transparent',
  // hover시 파란색 배경
  ':hover': {
    color: 'white',
    backgroundColor: '#3B8DC9',
  },
  selectors: {
    '&.current': { // 파란색 배경으로 현재 페이지 표시
      backgroundColor: '#3B8DC9',
      color: 'white',
    },
  },
  transition: 'background-color 0.3s ease, transform 0.3s ease',
});

5. 다음 페이지로 이동하는 버튼들을 생성한다

  • 왼쪽으로 이동하는 버튼

    버튼 아이콘은 react-icon을 사용했다

    <GoLeft
            currentPage={currentPage}
            setCurrentPage={setCurrentPage}
            setSlicedPageInex={setSlicedPageInex}
          />
    import React from 'react';
    import { AiOutlineLeft } from 'react-icons/ai';
    import { ButtonsStyle } from './PaginationButtons.css';
    import { MAXIMUM_PAGE_BUTTON_COUNT } from '@/config/const';
    interface GoLeftProps {
      currentPage: number;
      setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
      setSlicedPageInex: React.Dispatch<React.SetStateAction<number[]>>;
    }
    const GoLeft = ({ currentPage, setCurrentPage, setSlicedPageInex }: GoLeftProps) => {
      return (
        <button
          className={`${ButtonsStyle} ${currentPage === 1 && 'disabled'}`}
          disabled={currentPage === 1}
          onClick={() => {
            const nextPage = currentPage - 1;
            setCurrentPage(nextPage);
            if (currentPage % MAXIMUM_PAGE_BUTTON_COUNT === 1) {
              setSlicedPageInex([nextPage - MAXIMUM_PAGE_BUTTON_COUNT + 1, nextPage + 1]);
            }
          }}
        >
          <AiOutlineLeft />
        </button>
      );
    };
    
    export default GoLeft;
    1. 1페이지의 경우에는 disabled를 걸어준다

    2. 해당 버튼을 클릭했을 땐 currentPage를 -1 해준다

      이어서, 아래의 예시처럼 새로운 4개의 페이지로 이동해야 하는 경우, slicedPageInex를 수정 해줘야 한다

      5 6 7 8 (현재 페이지:5)
      (slicedPageInex : [5,9])
      
      (버튼 클릭)
      
      1 2 3 4 (현재 페이지:4)
      (slicedPageInex : [1,5])

      현재페이지%4 === 1 인 경우에 이에 해당하므로 이 경우에 slice인덱스를 같이 수정해준다

    3. disabled일 경우 hover를 했을 때는 파란색 배경을 띄워주면 안 된다.

      그러므로 이 경우에 대한 스타일을 추가한다.

      export const ButtonsStyle = style({
        display: 'flex',
        alignItems: 'center',
        fontSize: '1.1rem',
        padding: '2px 15px',
        margin: '15px',
        border: 'none',
        borderRadius: '20px',
        backgroundColor: 'transparent',
        ':hover': {
          color: 'white',
          backgroundColor: '#3B8DC9',
        },
        selectors: {
          '&.current': {
            backgroundColor: '#3B8DC9',
            color: 'white',
          },
      
           // disabled일 때 hover시, 짙은 회색으로 표시
          '&:disabled:hover': {
            backgroundColor: 'transparent',
            color: '#888',
          },
      
        },
        transition: 'background-color 0.3s ease, transform 0.3s ease',
      });
  • 오른쪽으로 이동하는 버튼
    <GoRight
            lastPageNumber={LAST_PAGE_NUMBER}
            currentPage={currentPage}
            setCurrentPage={setCurrentPage}
            setSlicedPageInex={setSlicedPageInex}
          />
    import React from 'react';
    import { AiOutlineRight } from 'react-icons/ai';
    import { ButtonsStyle } from './PaginationButtons.css';
    import { MAXIMUM_PAGE_BUTTON_COUNT } from '@/config/const';
    interface GoRightProps {
      lastPageNumber: number;
      currentPage: number;
      setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
      setSlicedPageInex: React.Dispatch<React.SetStateAction<number[]>>;
    }
    const GoRight = ({
      lastPageNumber,
      currentPage,
      setCurrentPage,
      setSlicedPageInex,
    }: GoRightProps) => {
      return (
        <button
          className={`${ButtonsStyle} ${currentPage === lastPageNumber && 'disabled'}`}
          disabled={currentPage === lastPageNumber}
          onClick={() => {
            const nextPage = currentPage + 1;
            setCurrentPage(nextPage);
    
            if (currentPage % MAXIMUM_PAGE_BUTTON_COUNT === 0) {
              const from = nextPage;
              const to = nextPage + MAXIMUM_PAGE_BUTTON_COUNT;
              if (to >= lastPageNumber + 1) setSlicedPageInex([from, lastPageNumber + 1]);
              if (to < lastPageNumber) setSlicedPageInex([from, to]);
            }
          }}
        >
          <AiOutlineRight />
        </button>
      );
    };
    
    export default GoRight;
    1. 마지막페이지의 경우에는 disabled를 걸어준다

    2. 해당 버튼을 클릭했을 땐 currentPage를 +1 해준다

      이어서, 여기서도 새로운 4개의 페이지로 이동해야 하는 경우에 slicedPageInex를 수정해줘야 한다

      1 2 3   4 (현재 페이지:4)
      (slicedPageInex : [1,5])
      
      (버튼 클릭)
      
      5   6 7 8 (현재 페이지:5)
      (slicedPageInex : [5,9])

      현재페이지%4 === 0 인 경우가 이에 해당한다.

      slice의 시작점인 from과 끝점인 to를 생성한다.


      다만, 다음 페이지가 마지막 페이지인데 갯수가 4개보다 작은 경우가 있다

      5 6 7   8 (현재 페이지:8)
      (slicedPageInex : [5,9])
      
      (버튼 클릭)
      
      9 10 (현패 페이지:9)
      (slicedPageInex : [9,11])
      
      lastPageNumber : 10
      기존 to: 13
      실제 to: 11

      이를 대비하기 위해 기존에 to로 설정했던 인덱스가 lastPageNumber + 1 와 같거나 크다면

      lastPageNumber + 1 을 to의 값으로 사용하는 것이다

      +1을 해주는 이유는 언급했듯이 slice를 사용하므로
      두번째 인덱스는 +1을 해줘야 하기 때문이다.

6. 양 끝으로 이동하는 버튼을 생성한다

  • 맨 처음으로 이동
    <GoStart
            currentPage={currentPage}
            lastPageNumber={LAST_PAGE_NUMBER}
            setCurrentPage={setCurrentPage}
            setSlicedPageInex={setSlicedPageInex}
          />
    import React from 'react';
    import { HiOutlineChevronDoubleLeft } from 'react-icons/hi';
    import { ButtonsStyle } from './PaginationButtons.css';
    import { MAXIMUM_PAGE_BUTTON_COUNT } from '@/config/const';
    
    interface GoStartProps {
      currentPage: number;
      lastPageNumber: number;
      setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
      setSlicedPageInex: React.Dispatch<React.SetStateAction<number[]>>;
    }
    
    const GoStart = ({
      currentPage,
      lastPageNumber,
      setCurrentPage,
      setSlicedPageInex,
    }: GoStartProps) => {
      return (
        <>
          <button
            className={`${ButtonsStyle} ${currentPage === 1 && 'disabled'}`}
            disabled={currentPage === 1}
            onClick={() => {
              setCurrentPage(1);
    
              let nextSlicedEnd;
              if (MAXIMUM_PAGE_BUTTON_COUNT > lastPageNumber) {
                nextSlicedEnd = lastPageNumber + 1;
              } else {
                nextSlicedEnd = 1 + MAXIMUM_PAGE_BUTTON_COUNT;
              }
    
              setSlicedPageInex([1, nextSlicedEnd]);
            }}
          >
            <HiOutlineChevronDoubleLeft />
          </button>
        </>
      );
    };
    
    export default GoStart;
    클릭시 첫 페이지로 이동한다 이 때 역시 slicedPageInex를 수정해야 한다. slicedPageInex의 첫번째 인자는 1로 고정하면 되므로 두번째 인자만 정해주면 된다

    주의해야 할 점은 전체 페이지가 4개 이하인 경우를
    따로 처리 해줘야 한다

    ( MAXIMUM_PAGE_BUTTON_COUNT 의 간격으로 slicedPageInex를 수정하면 4개 전체 페이지가 4개가 안 되는데 4개의 페이지가보여지게 된다 ) 아래의 경우를 말하는 것이다
    전체 페이지 : 1 2 
    
    (버튼 클릭)
    
    1   2  (현재 페이지:1)
    (slicedPageInex : [1,3])
    
    이를 위해 MAXIMUM_PAGE_BUTTON_COUNT > lastPageNumber 일 경우 slicedPageInex의 두번째 인자로 사용되는 값을 MAXIMUM_PAGE_BUTTON_COUNT + 1가 아닌, lastPageNumber + 1로 사용한다.
  • 맨 끝으로 이동

    <GoEnd
            lastPageNumber={LAST_PAGE_NUMBER}
            currentPage={currentPage}
            setCurrentPage={setCurrentPage}
            setSlicedPageInex={setSlicedPageInex}
          />
    import React from 'react';
    import { HiOutlineChevronDoubleRight } from 'react-icons/hi';
    import { ButtonsStyle } from './PaginationButtons.css';
    import { MAXIMUM_PAGE_BUTTON_COUNT } from '@/config/const';
    interface GoEndProps {
      lastPageNumber: number;
      currentPage: number;
      setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
      setSlicedPageInex: React.Dispatch<React.SetStateAction<number[]>>;
    }
    
    const GoEnd = ({ lastPageNumber, currentPage, setCurrentPage, setSlicedPageInex }: GoEndProps) => {
      return (
        <button
          className={`${ButtonsStyle} ${currentPage === lastPageNumber && 'disabled'}`}
          disabled={currentPage === lastPageNumber}
          onClick={() => {
            setCurrentPage(lastPageNumber);
    
            let nextSlicedStart;
            if (lastPageNumber % MAXIMUM_PAGE_BUTTON_COUNT === 0) {
              nextSlicedStart = lastPageNumber - MAXIMUM_PAGE_BUTTON_COUNT + 1;
            } else {
              nextSlicedStart =
                lastPageNumber -
                MAXIMUM_PAGE_BUTTON_COUNT +
                1 +
                (MAXIMUM_PAGE_BUTTON_COUNT - (lastPageNumber % MAXIMUM_PAGE_BUTTON_COUNT));
            }
    
            setSlicedPageInex([nextSlicedStart, lastPageNumber + 1]);
          }}
        >
          <HiOutlineChevronDoubleRight />
        </button>
      );
    };
    
    export default GoEnd;

    클릭시 마지막 페이지로 이동한다.

    이 때 역시 slicedPageInex를 수정해야 한다.

    slicedPageInex의 두번째 인자는 마지막 페이지로 고정하면 되므로 첫번째 인자만 정해주면 된다

    마지막 페지이의 변호가 MAXIMUM_PAGE_BUTTON_COUNT로

    지정한 4의 배수이면 (lastPageNumber - 4)+1 한 값을 사용한다

    <전체 페이지>
    1 2 3 4   5 6 7 8   9 10 11 12
    
    (버튼 클릭)
    
    <현재 페이지>
    9 10 11 12
    slicedPageInex : [9,12]


    그렇지만, 페지이의 변호가 MAXIMUM_PAGE_BUTTON_COUNT

    로 지정한 4의 배수가 아니라면 계산을 조금 다르게 해야 한다

    <전체 페이지>
    1 2 3 4   5 6 7 8   9 10 11
    
    (버튼 클릭)
    
    <현재 페이지>
    9 10 11
    slicedPageInex : [9,12]
     (11 - 4) + (4-(11%4))   =  9
    // 4는 MAXIMUM_PAGE_BUTTON_COUNT
    // 11은 마지막 페이지 번호

7. 잔여 페이지가 남았을 경우 … 으로 표시한다

잔여 페이지 여부는 현재 표시되고 있는 버튼의 인덱스인

slicedPageInex를 기반으로 진행한다

LuMoreHorizontal는 … 을 보여주는 react-icon이다

{slicedPageInex[0] !== 1 && <LuMoreHorizontal />}

{slicedPageInex[1] !== LAST_PAGE_NUMBER + 1 && <LuMoreHorizontal />}



최종적으로 PaginationButtons 컴포넌트가 완성됐다

하나 주의해야 할 점은, 현재 제출 현황페이지에서

  • 내 제출 페이지
  • 정답 보기 페이지

이 2개는 같은 페이지이고 쿼리스트링 기반으로 재사용 하고 있다.

그렇기 때문에 내 제출 페이지와 정답 보기를 왔다 갔다 할 때마다

제출 내역의 정보가 아예 달라지게 된다

이는 페이지들, 버튼들의 갯수도 달라지는 것이므로 반드시
2개의 페이지를 넘나들 때마다 slicedPageInex를 초기 상태로 다시
세팅해주고,
리렌더링이 되면서 버튼의 갯수를 담당하는
LAST_PAGE_NUMBER 역시 다시 계산되도록 해야 한다

import React, { useEffect, useState } from 'react';
...

/**
 *
 * 제출 현황 페이지에서 사용되는 URL 명세입니다.
 * https://localhost:3000/status?[result_type=right]&problem_id=172&[snsId=123]
 * @route GET /status
 * @param {string} query.result_id -  정답 옵션입니다 right, wrong, correct, valid (없을 경우 전체보기)
 * @param {number} query.problem_id - 문제의 ID를 나타냅니다.
 * @param {string} query.sns_id - GITHUB의 유저ID값을 의미합니다 (존재할 경우 내 제출, 없을 경우 모든 유저 제출)
 * @returns {Object} Response object
 */

const Status = () => {
  const [currentPage, setCurrentPage] = useState(1);

  const location = useLocation();
  const queryParams = new URLSearchParams(location.search);

  const [resultType, problemId, snsId] = [
    queryParams.get('result_type') as SubmitResultsType,
    Number(queryParams.get('problem_id')),
    Number(queryParams.get('sns_id')),
  ];

  const { data: submitList, refetch: submitListRefetch } = useGetSubmitList({
    resultType,
    problemId,
    snsId,
    currentPage,
  });

  const { data: submitSize, refetch: submitSizeRefetch } = useGetSubmitSize({
    resultType,
    problemId,
    snsId,
  });

  const totalSize = submitSize?.data?.data;
  const list = submitList?.data.data;

  **// 내 제출과 정답 보기페이지로 서로 이동 할 때**
  useEffect(() => {
    submitSizeRefetch(); **// 전체 제출 길이 refetch**
    submitListRefetch(); **// 전체 제출 리스트 정보 refetch**
		setCurrentPage(1); **// 1페이지로 초기화**
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [snsId]);

  return (
    <div>
      <table className={TableStyle}>
        <StatusHeader />
        {list != null && <StatusBody list={list} />}
      </table>

      {totalSize != null && totalSize !== 0 && (
				**// 페이지네이션 버튼**
        **<PaginationButtons
          totalSize={totalSize}
          currentPage={currentPage}
          setCurrentPage={setCurrentPage}
        />**
      )}
    </div>
  );
};

export default Status;
import React, { useEffect, useState } from 'react';
...


interface PaginationButtonsProps {
  totalSize: number;
  currentPage: number;
  setCurrentPage: React.Dispatch<React.SetStateAction<number>>;
}
const PaginationButtons = ({ totalSize, currentPage, setCurrentPage }: PaginationButtonsProps) => {
  const location = useLocation();
  const queryParams = new URLSearchParams(location.search);

  const snsId = Number(queryParams.get('sns_id'));
  const LAST_PAGE_NUMBER = Math.ceil(totalSize / COUNT_PER_PAGE);
  const [slicedPageInex, setSlicedPageInex] = useState([
    1,
    getLastPageSlicedIndexAtFirst(LAST_PAGE_NUMBER),
  ]);

  **// 내 제출과 정답 보기페이지로 서로 이동 할 때, 
  // 슬라이싱 인덱스를 초기 상태로 변경**
  useEffect(() => {
    setSlicedPageInex([1, getLastPageSlicedIndexAtFirst(LAST_PAGE_NUMBER)]);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [snsId]);

  return (
    <div className={PaginationButtonsWrapperStyle}>
      <GoStart
        currentPage={currentPage}
        lastPageNumber={LAST_PAGE_NUMBER}
        setCurrentPage={setCurrentPage}
        setSlicedPageInex={setSlicedPageInex}
      />
      <GoLeft
        currentPage={currentPage}
        setCurrentPage={setCurrentPage}
        setSlicedPageInex={setSlicedPageInex}
      />
      {slicedPageInex[0] !== 1 && <LuMoreHorizontal />}
      {new Array(LAST_PAGE_NUMBER + 1)
        .fill(0)
        .map((_, arrIndex: number) => arrIndex)
        .slice(slicedPageInex[0], slicedPageInex[1])
        .map((pageNumber, pageIndex) => {
          return (
            <button
              key={pageIndex}
              type="button"
              className={`${ButtonsStyle} ${currentPage === pageNumber && 'current'}`}
              onClick={() => {
                setCurrentPage(pageNumber);
              }}
            >
              {pageNumber}
            </button>
          );
        })}
      {slicedPageInex[1] !== LAST_PAGE_NUMBER + 1 && <LuMoreHorizontal />}
      <GoRight
        lastPageNumber={LAST_PAGE_NUMBER}
        currentPage={currentPage}
        setCurrentPage={setCurrentPage}
        setSlicedPageInex={setSlicedPageInex}
      />
      <GoEnd
        lastPageNumber={LAST_PAGE_NUMBER}
        currentPage={currentPage}
        setCurrentPage={setCurrentPage}
        setSlicedPageInex={setSlicedPageInex}
      />
    </div>
  );
};

export default PaginationButtons;
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 11월 13일

우와 신기하네요

답글 달기