콘텐츠 대표 이미지 - ✨ Intersection Observer API로 초간단 무한 스크롤 구현하기: 웹 개발자의 필수 스킬! ✨

✨ Intersection Observer API로 초간단 무한 스크롤 구현하기: 웹 개발자의 필수 스킬! ✨

안녕하세요, 개발자 여러분! 오늘은 웹사이트의 UX를 확 끌어올려줄 무한 스크롤(Infinite Scroll) 기능을 구현하는 방법에 대해 알아볼게요. 인스타그램, 페이스북, 트위터 같은 SNS에서 자주 볼 수 있는 이 기능, 어떻게 만들 수 있는지 궁금하셨죠? 😎

특히 요즘 트렌드인 Intersection Observer API를 활용하면 정말 간단하고 효율적으로 구현할 수 있어요! 스크롤 이벤트와 씨름하던 시절은 이제 안녕~ 👋

👁️ Intersection Observer 감지 지점 Intersection Observer로 무한 스크롤 구현하기
🔍 📱 💻 🚀

📚 목차

  1. 무한 스크롤이란?
  2. 기존 스크롤 이벤트의 문제점
  3. Intersection Observer API 소개
  4. Intersection Observer API 기본 사용법
  5. 무한 스크롤 구현하기
  6. 실제 프로젝트 적용 예시
  7. 성능 최적화 팁
  8. 브라우저 호환성 및 폴리필
  9. 마무리 및 추가 자료
🌈 🔄 ♾️ 🧩

1. 무한 스크롤이란? 🤔

무한 스크롤(Infinite Scroll)은 사용자가 페이지의 하단에 도달했을 때 자동으로 새로운 콘텐츠를 로드하는 UX 패턴이에요. 페이지네이션(pagination)과 달리 사용자가 '다음 페이지' 버튼을 클릭할 필요 없이 스크롤만 하면 계속해서 새로운 콘텐츠를 볼 수 있죠.

요즘 SNS나 콘텐츠 플랫폼에서 정말 흔하게 볼 수 있는 이 기능, 여러분도 매일 사용하고 계실 거예요! 인스타그램 피드를 내리다 보면 새 게시물이 자동으로 로드되는 그 경험, 바로 무한 스크롤이랍니다. ㅋㅋㅋ

🌟 무한 스크롤의 장점

  1. 사용자 참여도 증가 - 계속 스크롤하게 만들어 체류 시간 증가
  2. UX 개선 - 클릭 없이 자연스러운 콘텐츠 탐색 가능
  3. 초기 로딩 시간 단축 - 필요한 데이터만 먼저 로드
  4. 모바일 친화적 - 스크롤이 터치 기반 디바이스에 적합

하지만 무한 스크롤이 만능은 아니에요! 푸터에 접근하기 어렵고, 특정 위치로 돌아가기 힘들며, 검색엔진 최적화(SEO)에 불리할 수 있어요. 그래서 상황에 맞게 사용하는 것이 중요합니다! 😉

재능넷 같은 재능 공유 플랫폼에서도 다양한 재능 상품을 탐색할 때 무한 스크롤을 적용하면 사용자 경험이 훨씬 좋아질 수 있어요. 특히 모바일에서 재능 검색 결과를 보여줄 때 정말 유용하죠!

2. 기존 스크롤 이벤트의 문제점 😱

전통적으로 무한 스크롤은 scroll 이벤트 리스너를 사용해 구현했어요. 사용자가 페이지 하단에 도달했는지 체크하기 위해 이런 코드를 많이 썼죠:

window.addEventListener('scroll', function() {
  if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
    // 페이지 하단에 도달! 새 콘텐츠 로드
    loadMoreContent();
  }
});

근데 이 방식에는 심각한 성능 문제가 있어요! 😵

⚠️ 스크롤 이벤트의 문제점

  1. 스크롤 이벤트는 매우 빈번하게 발생 - 스크롤할 때마다 수십~수백 번 호출됨
  2. 메인 스레드 블로킹 - 스크롤 핸들러가 무거우면 UI가 버벅임
  3. 디바운싱/쓰로틀링 필요 - 추가 코드 작성해야 함
  4. 계산 복잡 - 요소의 위치와 뷰포트 관계 계산이 까다로움

이런 문제 때문에 스크롤 이벤트로 무한 스크롤 구현하면 성능 이슈가 자주 발생했어요. 특히 모바일에서는 더 심각하고요. 그래서 개발자들은 이벤트를 최적화하기 위해 디바운싱(debouncing)이나 쓰로틀링(throttling) 같은 기법을 사용했죠.

// 쓰로틀링 예시
function throttle(fn, delay) {
  let lastCall = 0;
  return function(...args) {
    const now = new Date().getTime();
    if (now - lastCall < delay) {
      return;
    }
    lastCall = now;
    return fn(...args);
  };
}

window.addEventListener('scroll', throttle(function() {
  // 스크롤 처리 로직
}, 200));

근데 이것도 완벽한 해결책은 아니었어요. 결국 스크롤 이벤트 자체가 너무 자주 발생하는 게 문제니까요! 😫

이런 문제를 해결하기 위해 등장한 게 바로 Intersection Observer API예요! 이제 그 해결사를 만나볼 시간이에요~ 🎉

스크롤 이벤트 방식 CPU 사용량 매우 빈번한 호출 (성능 저하) 코드 복잡성 • 디바운싱/쓰로틀링 필요 • 위치 계산 로직 복잡 Intersection Observer 방식 CPU 사용량 필요할 때만 콜백 실행 (효율적) 코드 단순성 • 선언적 API • 자동 위치 감지 VS

3. Intersection Observer API 소개 🔍

Intersection Observer API는 요소가 뷰포트(화면에 보이는 영역)에 들어오거나 나갈 때 비동기적으로 알려주는 웹 API예요. 쉽게 말해서, "이 요소가 화면에 보이는지 감시해줘~" 하고 부탁하면 알아서 체크해주는 똑똑한 친구죠! 😄

💡 Intersection Observer의 핵심 장점

  1. 비동기적 작동 - 메인 스레드 블로킹 없음
  2. 성능 효율적 - 필요할 때만 콜백 실행
  3. 간결한 코드 - 복잡한 계산 필요 없음
  4. 배터리 친화적 - 모바일 기기에서 중요!

무한 스크롤 외에도 Intersection Observer는 레이지 로딩(lazy loading), 애니메이션 트리거, 광고 노출 측정 등 다양한 용도로 활용할 수 있어요. 진짜 만능 도구라니까요! 👍

재능넷 같은 플랫폼에서도 이미지가 많은 재능 상품 목록을 표시할 때 Intersection Observer를 활용한 레이지 로딩을 적용하면 초기 로딩 속도를 크게 개선할 수 있어요. 사용자 경험이 확 좋아지죠!

4. Intersection Observer API 기본 사용법 📝

자, 이제 본격적으로 Intersection Observer를 사용해볼게요! 기본 문법은 정말 간단해요.

// 1. 옵저버 콜백 함수 정의
const callback = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('요소가 화면에 보임!', entry.target);
      // 필요한 작업 수행
    }
  });
};

// 2. 옵저버 옵션 설정
const options = {
  root: null, // 뷰포트를 root로 사용
  rootMargin: '0px', // root 요소의 마진
  threshold: 0.1 // 10% 이상 보일 때 콜백 실행
};

// 3. 옵저버 생성
const observer = new IntersectionObserver(callback, options);

// 4. 관찰할 요소 지정
const target = document.querySelector('#my-element');
observer.observe(target);

위 코드를 보면 4단계로 구성되어 있어요:

  1. 콜백 함수 정의 - 요소가 화면에 보일 때 실행할 함수
  2. 옵션 설정 - 언제, 어떻게 관찰할지 설정
  3. 옵저버 생성 - 콜백과 옵션으로 옵저버 인스턴스 생성
  4. 관찰 시작 - 어떤 요소를 관찰할지 지정

이제 각 옵션에 대해 자세히 알아볼게요! 😊

🛠️ Intersection Observer 옵션 상세 설명

root

root는 타겟 요소의 가시성을 확인하는 기준이 되는 요소예요. null로 설정하면 브라우저 뷰포트가 기준이 됩니다.

// 특정 컨테이너를 기준으로 관찰
const options = {
  root: document.querySelector('#scrollContainer')
};

rootMargin

rootMargin은 root 요소의 마진을 설정해요. CSS 마진처럼 "top right bottom left" 순서로 지정할 수 있어요.

// root 요소 경계 바깥으로 100px 확장
const options = {
  rootMargin: '100px 0px 100px 0px'
};

threshold

threshold는 콜백이 실행되는 타겟 요소의 가시성 비율이에요. 0.0(보이자마자)부터 1.0(완전히 보일 때)까지 설정 가능해요.

// 여러 지점에서 콜백 실행
const options = {
  threshold: [0, 0.25, 0.5, 0.75, 1]
};

콜백 함수에서 받는 entries 매개변수는 IntersectionObserverEntry 객체의 배열이에요. 이 객체에는 다음과 같은 유용한 속성들이 있어요:

  • isIntersecting: 요소가 뷰포트에 보이는지 여부 (boolean)
  • target: 관찰 중인 요소
  • intersectionRatio: 요소가 보이는 비율 (0.0 ~ 1.0)
  • boundingClientRect: 요소의 경계 사각형 정보
  • intersectionRect: 교차 영역의 경계 사각형
  • time: 교차가 발생한 시간

이제 기본 사용법을 알았으니, 본격적으로 무한 스크롤을 구현해볼까요? 🚀

Intersection Observer 작동 원리 뷰포트 (Viewport) 관찰 대상 요소 (아직 뷰포트 밖) 👁️ 스크롤 시 요소가 뷰포트에 들어오면 콜백 실행 rootMargin threshold

5. 무한 스크롤 구현하기 🔄

이제 Intersection Observer를 사용해서 무한 스크롤을 구현해볼게요! 기본 아이디어는 간단해요:

  1. 페이지 하단에 '감시 요소'를 배치
  2. 이 요소가 화면에 보이면 새 콘텐츠 로드
  3. 새 콘텐츠가 추가되면 감시 요소는 자동으로 아래로 밀림
  4. 사용자가 계속 스크롤하면 과정 반복

정말 간단하죠? 이제 코드로 구현해볼게요!

// HTML 구조
<div id="content-container">
  <!-- 초기 콘텐츠 아이템들 -->
  <div class="content-item">아이템 1</div>
  <div class="content-item">아이템 2</div>
  <div class="content-item">아이템 3</div>
  <!-- ... -->
</div>

<!-- 감시 요소 (sentinel) -->
<div id="sentinel"></div>
// JavaScript 구현
// 페이지 번호 관리
let currentPage = 1;
let isLoading = false;

// Intersection Observer 콜백
const handleIntersect = (entries, observer) => {
  // 감시 요소가 화면에 보이는지 확인
  if (entries[0].isIntersecting && !isLoading) {
    loadMoreContent();
  }
};

// 새 콘텐츠 로드 함수
const loadMoreContent = async () => {
  try {
    isLoading = true;
    
    // 로딩 표시 (옵션)
    showLoadingIndicator();
    
    // 다음 페이지 데이터 가져오기
    const nextPage = currentPage + 1;
    const response = await fetch(`/api/content?page=${nextPage}`);
    const data = await response.json();
    
    // 데이터가 없으면 관찰 중단
    if (data.items.length === 0) {
      observer.unobserve(sentinel);
      hideLoadingIndicator();
      showEndMessage();
      return;
    }
    
    // 새 콘텐츠 추가
    appendContent(data.items);
    
    // 페이지 번호 업데이트
    currentPage = nextPage;
  } catch (error) {
    console.error('콘텐츠 로드 오류:', error);
  } finally {
    isLoading = false;
    hideLoadingIndicator();
  }
};

// 콘텐츠 추가 함수
const appendContent = (items) => {
  const container = document.getElementById('content-container');
  
  items.forEach(item => {
    const element = document.createElement('div');
    element.className = 'content-item';
    element.textContent = item.title; // 또는 더 복잡한 HTML 구조
    container.appendChild(element);
  });
};

// 로딩 표시 함수들
const showLoadingIndicator = () => {
  // 로딩 스피너 표시 로직
};

const hideLoadingIndicator = () => {
  // 로딩 스피너 숨김 로직
};

const showEndMessage = () => {
  // "더 이상 콘텐츠가 없습니다" 메시지 표시
};

// Intersection Observer 생성
const options = {
  rootMargin: '100px', // 하단에서 100px 전에 로드 시작
};

const sentinel = document.getElementById('sentinel');
const observer = new IntersectionObserver(handleIntersect, options);

// 관찰 시작
observer.observe(sentinel);

위 코드의 핵심 포인트를 살펴볼게요:

🔑 무한 스크롤 구현의 핵심 포인트

  1. 감시 요소(sentinel) - 페이지 하단에 배치해 뷰포트 진입을 감지
  2. 로딩 상태 관리 - isLoading 플래그로 중복 요청 방지
  3. 페이지 번호 관리 - currentPage로 다음에 로드할 페이지 추적
  4. 에러 처리 - try/catch로 오류 상황 대응
  5. 종료 조건 - 더 이상 데이터가 없을 때 관찰 중단

rootMargin: '100px' 옵션을 사용해서 사용자가 페이지 하단에 도달하기 100px 전에 새 콘텐츠 로드를 시작해요. 이렇게 하면 사용자는 로딩을 기다리지 않고 끊김 없이 콘텐츠를 볼 수 있죠! 👌

또한 isLoading 플래그를 사용해 중복 요청을 방지하는 것도 중요해요. 이게 없으면 스크롤이 감시 요소에 머무는 동안 계속해서 요청이 발생할 수 있거든요! 😱

6. 실제 프로젝트 적용 예시 🏗️

이제 실제 프로젝트에 적용하는 방법을 알아볼게요. 여기서는 React와 함께 사용하는 예시를 보여드릴게요. 물론 Vue, Angular 등 다른 프레임워크에서도 비슷한 방식으로 적용할 수 있어요!

React에서 무한 스크롤 구현하기

import React, { useState, useEffect, useRef, useCallback } from 'react';

function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef();
  const lastItemRef = useRef();

  // 데이터 가져오기
  const fetchData = useCallback(async () => {
    try {
      setLoading(true);
      const response = await fetch(`/api/items?page=${page}`);
      const data = await response.json();
      
      if (data.items.length === 0) {
        setHasMore(false);
      } else {
        setItems(prevItems => [...prevItems, ...data.items]);
        setPage(prevPage => prevPage + 1);
      }
    } catch (error) {
      console.error('데이터 로드 오류:', error);
    } finally {
      setLoading(false);
    }
  }, [page]);

  // Intersection Observer 설정
  useEffect(() => {
    if (loading || !hasMore) return;
    
    if (observer.current) observer.current.disconnect();
    
    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        fetchData();
      }
    }, { rootMargin: '100px' });
    
    if (lastItemRef.current) {
      observer.current.observe(lastItemRef.current);
    }
    
    return () => {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
  }, [loading, hasMore, fetchData]);

  return (
    <div className="item-list">
      {items.map((item, index) => {
        // 마지막 아이템에 ref 연결
        if (index === items.length - 1) {
          return (
            <div 
              key={item.id} 
              ref={lastItemRef}
              className="item"
            >
              {item.title}
            </div>
          );
        } else {
          return (
            <div key={item.id} className="item">
              {item.title}
            </div>
          );
        }
      })}
      
      {loading && <div className="loading">로딩 중...</div>}
      {!hasMore && <div className="end-message">더 이상 아이템이 없습니다</div>}
    </div>
  );
}

export default InfiniteScrollList;

React에서는 useRef와 useEffect 훅을 활용해 Intersection Observer를 구현했어요. 특이한 점은 별도의 감시 요소 대신 마지막 아이템 자체를 관찰한다는 거예요! 이렇게 하면 HTML 구조가 더 깔끔해지죠. 😊

이 패턴은 재능넷 같은 플랫폼에서 재능 상품 목록을 무한 스크롤로 표시할 때 정말 유용해요. 사용자가 스크롤할 때마다 자연스럽게 더 많은 재능 상품들이 로드되니까요!

커스텀 훅으로 재사용하기

React에서는 이 기능을 커스텀 훅으로 분리해서 여러 컴포넌트에서 재사용할 수도 있어요:

// useInfiniteScroll.js
import { useState, useEffect, useRef, useCallback } from 'react';

export function useInfiniteScroll(fetchCallback) {
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef();
  
  // 마지막 요소 ref 설정 함수
  const lastElementRef = useCallback(node => {
    if (loading) return;
    
    if (observer.current) observer.current.disconnect();
    
    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore) {
        setLoading(true);
        fetchCallback().then(hasMoreData => {
          setHasMore(hasMoreData);
          setLoading(false);
        });
      }
    }, { rootMargin: '100px' });
    
    if (node) observer.current.observe(node);
  }, [loading, hasMore, fetchCallback]);
  
  // 클린업
  useEffect(() => {
    return () => {
      if (observer.current) {
        observer.current.disconnect();
      }
    };
  }, []);
  
  return { lastElementRef, loading, hasMore };
}

// 사용 예시
function ItemList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  
  const fetchItems = useCallback(async () => {
    const response = await fetch(`/api/items?page=${page}`);
    const data = await response.json();
    
    setItems(prev => [...prev, ...data.items]);
    setPage(prev => prev + 1);
    
    return data.items.length > 0;
  }, [page]);
  
  const { lastElementRef, loading, hasMore } = useInfiniteScroll(fetchItems);
  
  return (
    <div>
      {items.map((item, index) => (
        <div 
          key={item.id}
          ref={index === items.length - 1 ? lastElementRef : null}
        >
          {item.title}
        </div>
      ))}
      {loading && "로딩 중..."}
      {!hasMore && "더 이상 아이템이 없습니다"}
    </div>
  );
}

이렇게 커스텀 훅으로 분리하면 코드 재사용성이 높아지고, 컴포넌트 로직이 더 깔끔해져요! 👍

7. 성능 최적화 팁 🚀

무한 스크롤은 편리하지만, 계속 콘텐츠를 추가하다 보면 DOM 요소가 너무 많아져 성능 문제가 발생할 수 있어요. 이를 방지하기 위한 몇 가지 팁을 알려드릴게요!

⚡ 무한 스크롤 성능 최적화 팁

1. 가상화(Virtualization) 적용

화면에 보이는 요소만 렌더링하고 나머지는 가상으로 처리하는 기법이에요. React에서는 react-windowreact-virtualized 같은 라이브러리를 사용할 수 있어요.

import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].title}
    </div>
  );

  return (
    <FixedSizeList
      height={500}
      width="100%"
      itemCount={items.length}
      itemSize={50}
    >
      {Row}
    </FixedSizeList>
  );
}

2. 페이지 단위 언마운트

너무 많은 페이지가 로드되면 이전 페이지의 요소를 DOM에서 제거하는 방법이에요.

// 최대 3페이지만 유지하는 예시
if (currentPage > 3) {
  // 첫 페이지 요소들 제거
  const firstPageItems = document.querySelectorAll('.page-1 .item');
  firstPageItems.forEach(item => item.remove());
}

3. 이미지 최적화

이미지가 많은 콘텐츠라면 레이지 로딩을 적용하고, 적절한 크기와 포맷의 이미지를 사용해야 해요.

<img 
  src="placeholder.jpg" 
  data-src="actual-image.jpg" 
  loading="lazy" 
  class="lazy-image" 
/>

4. 디바운싱 적용

스크롤 이벤트를 사용하는 경우 디바운싱이나 쓰로틀링을 적용해 호출 빈도를 제한해요. (Intersection Observer 사용 시에는 덜 중요)

특히 가상화(Virtualization) 기법은 정말 효과적이에요! 수천 개의 아이템을 다루는 경우에도 부드러운 스크롤을 유지할 수 있거든요. 재능넷처럼 많은 상품을 보여주는 플랫폼에서는 꼭 고려해볼 만한 기술이에요! 🔥

가상화(Virtualization) 작동 원리 일반 무한 스크롤 아이템 1 (DOM에 존재) 아이템 2 (DOM에 존재) 아이템 3 (DOM에 존재) 아이템 4 (DOM에 존재) 아이템 5 (DOM에 존재) 아이템 6 (DOM에 존재) 높은 메모리 사용량 가상화된 무한 스크롤 아이템 1 (가상) 아이템 2 (DOM에 존재) 아이템 3 (DOM에 존재) 아이템 4 (DOM에 존재) 아이템 5 (가상) 아이템 6 (가상) 현재 뷰포트 낮은 메모리 사용량 VS

8. 브라우저 호환성 및 폴리필 🌐

Intersection Observer API는 최신 브라우저에서는 대부분 지원하지만, IE 같은 구형 브라우저에서는 지원하지 않아요. 2023년 기준으로 전 세계 브라우저의 약 95% 이상이 지원하고 있어요!

🌍 브라우저 호환성

✅ Chrome 51+ (2016년 5월)

✅ Firefox 55+ (2017년 8월)

✅ Safari 12.1+ (2019년 3월)

✅ Edge 15+ (2017년 4월)

❌ Internet Explorer (지원 안 함)

구형 브라우저 지원이 필요하다면 폴리필(polyfill)을 사용하면 돼요! W3C에서 공식 폴리필을 제공하고 있어요:

// 폴리필 설치
npm install intersection-observer

// 또는 CDN 사용
<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>

// 코드 시작 부분에 임포트
import 'intersection-observer';

폴리필을 사용하면 구형 브라우저에서도 Intersection Observer API를 사용할 수 있어요. 다만, 네이티브 구현보다는 성능이 떨어질 수 있으니 참고하세요!

재능넷 같은 서비스를 개발할 때도 다양한 사용자층을 고려해 폴리필 적용을 검토해보는 것이 좋아요. 특히 기업 환경에서는 아직 구형 브라우저를 사용하는 경우가 있으니까요! 😉

9. 마무리 및 추가 자료 📝

지금까지 Intersection Observer API를 활용한 무한 스크롤 구현 방법에 대해 알아봤어요! 정리해보면:

📌 핵심 요약

  1. Intersection Observer API는 요소가 뷰포트에 들어오는 것을 감지하는 효율적인 방법
  2. 기존 scroll 이벤트보다 성능이 훨씬 좋고 코드도 간결
  3. 무한 스크롤 구현 시 페이지 하단에 감시 요소(sentinel)를 배치하는 패턴 사용
  4. React 등 프레임워크에서도 쉽게 적용 가능
  5. 가상화(Virtualization) 같은 성능 최적화 기법을 함께 사용하면 더 효율적
  6. 대부분의 최신 브라우저에서 지원하며, 구형 브라우저는 폴리필로 대응 가능

무한 스크롤은 사용자 경험을 크게 향상시킬 수 있는 기능이에요. 특히 재능넷 같은 재능 거래 플랫폼에서는 사용자가 다양한 재능 상품을 끊김 없이 탐색할 수 있게 해주죠! 😊

더 깊이 공부하고 싶다면 아래 자료들을 참고해보세요:

무한 스크롤 구현에 도전해보세요! 처음에는 어려워 보일 수 있지만, Intersection Observer API를 사용하면 생각보다 훨씬 쉽게 구현할 수 있어요. 여러분의 웹사이트나 앱에 적용해서 사용자 경험을 한 단계 업그레이드해보세요! 🚀

혹시 무한 스크롤 구현 중에 문제가 생기거나 도움이 필요하다면, 재능넷에서 웹 개발 전문가를 찾아보는 것도 좋은 방법이에요. 다양한 재능을 가진 전문가들이 여러분의 프로젝트를 도와드릴 거예요! 💪

👋 마치며

Intersection Observer API로 무한 스크롤을 구현하는 방법, 이해가 되셨나요? ㅋㅋㅋ

여러분의 웹사이트에 적용해보시고, 멋진 사용자 경험을 만들어보세요!

코딩 즐겁게 하세요~ 화이팅! 🔥

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0