JavaScript

웹 개발의 필수 언어

동적인 웹 페이지 구현을 위한 핵심 프로그래밍 언어.

Java

객체지향 프로그래밍

안정적이고 확장성 있는 백엔드 개발의 대표 언어.

HTML

웹의 기초

웹 페이지의 구조를 정의하는 마크업 언어.

React

현대적 UI 라이브러리

효율적인 사용자 인터페이스 구축을 위한 JavaScript 라이브러리.

CSS

웹 디자인의 핵심

웹 페이지의 시각적 표현을 담당하는 스타일 언어.

Spring

자바 웹 프레임워크

기업급 애플리케이션 개발을 위한 강력한 프레임워크.

React/예제

컴포넌트 재사용성 - 유튜브 아이템 추가

lamarcK 2025. 4. 9. 09:33

각각 반응형으로 크기 조절 하는 그리드 아이템 포함

 

비디오 생성 컴포넌트에 map 사용해서 배열을 할당하고 해당 배열을 SQL.db 파일을 가져와서 json으로 할당

이미지 경로는 public\assets\images

pageConfig 파일로 공동 변수 관리

 

app.jsx

무한 스크롤 로딩

child 호출로 동영상 아이템 만들기 포함

더보기
import React, { useState, useEffect, useCallback, useRef } from 'react'; // useCallback 추가
import './App.css'
import VideoContainer from './VideoContainer'
import { PAGE_CONFIG } from '../config/pageConfig';

function VideoList() {
  const [videoData, setVideoData] = useState([]); // 비디오 데이터 목록
  const [currentPage, setCurrentPage] = useState(1); // 현재 페이지 번호 (1부터 시작)
  const [loading, setLoading] = useState(false); // 데이터 로딩 중 상태
  const [hasMore, setHasMore] = useState(true); // 더 로드할 데이터가 있는지 여부
  const limit = PAGE_CONFIG.ITEMS_PER_PAGE;; // 페이지당 불러올 비디오 개수
  const observerTarget = useRef(null); // 📌 감시할 타겟 요소 Ref
  const mountedRef = useRef(false); // 추가: 마운트 상태 추적을 위한 ref


  // ✨ 비디오 데이터를 로드하는 함수 (페이지네이션 적용)
  const loadVideos = useCallback(async () => {
    // 로딩 중이거나 더 이상 데이터가 없으면 실행 중지
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      // 📌 API 요청 URL 변경: `offset` 대신 `page`와 `limit` 사용
      const response = await fetch(`http://localhost:3001/videos?page=${currentPage}&limit=${limit}`);
      if (!response.ok) { // HTTP 응답 상태 확인
          throw new Error(`HTTP error! status: ${response.status}`);
      }

      // 📌 API 응답 구조 변경에 따른 처리
      //    - 백엔드가 { data: [...], pagination: {...} } 형태로 응답
      const result = await response.json();
      const newVideos = result.data; // 실제 비디오 데이터 배열
      const pagination = result.pagination; // 페이지네이션 정보

      console.log(`[Frontend] Loaded page ${pagination.currentPage}/${pagination.totalPages}, Videos: ${newVideos.length}`);

      // 받아온 비디오 데이터가 있으면 기존 목록에 추가
      if (newVideos.length > 0) {
        setVideoData(prev => [...prev, ...newVideos]);
      }

      // 📌 더 로드할 데이터가 있는지 판단 (hasMore 상태 업데이트)
      //    - 현재 페이지가 총 페이지 수보다 크거나 같으면 더 이상 데이터가 없음
      if (pagination.currentPage >= pagination.totalPages) {
        setHasMore(false);
        console.log("[Frontend] No more videos to load.");
      } else {
         // 다음 페이지 로드를 위해 현재 페이지 번호 증가 (다음번 loadVideos 호출 시 사용됨)
         setCurrentPage(prevPage => prevPage + 1);
      }

    } catch (error) {
      console.error('비디오 데이터 로딩 에러:', error);
      // 에러 발생 시 더 이상 로드를 시도하지 않도록 설정할 수 있음
      // setHasMore(false);
    } finally {
      // 로딩 상태 해제 (성공/실패 여부와 관계없이 실행)
      setLoading(false);
    }
  }, [currentPage, hasMore, loading, limit]); // useCallback 의존성 배열 업데이트

  // 컴포넌트 마운트 시 첫 페이지 데이터 로드

  // 초기 데이터 로드
  useEffect(() => {
    if (mountedRef.current) return; // 이미 마운트된 경우 실행하지 않음
    mountedRef.current = true;
    loadVideos();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []); // 초기 로드는 loadVideos의 초기 상태로 한 번만 실행

// 📌 Intersection Observer 설정 useEffect
useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      // entries[0]가 IntersectionObserverEntry 객체
      if (entries[0]?.isIntersecting && hasMore && !loading) {
        console.log('IntersectionObserver: Target visible, loading more...');
        loadVideos();

      }
    },
    {
      threshold: 0.1, // 타겟 요소가 10% 보일 때 콜백 실행 (조정 가능)
    }
  );

  // observer가 타겟 요소를 감시 시작
  const currentTarget = observerTarget.current;
  if (currentTarget) {
    observer.observe(currentTarget);
  }

  // cleanup 함수: 컴포넌트 언마운트 시 감시 중단
  return () => {
    if (currentTarget) {
      observer.unobserve(currentTarget);
    }
  };
  // hasMore나 loading 상태가 변경되면 observer 로직이 최신 상태를 참조하도록 함
  // loadVideos 자체도 의존성에 넣어 최신 함수를 호출하도록 보장
}, [hasMore, loading, loadVideos]);

return (
  <>
    <div className='grid-container'>
    {/* 🎬 비디오 목록 표시: videoData 상태를 VideoContainer 컴포넌트에 전달 */}
    <VideoContainer videos={videoData} />
    </div>
    {/* ⏳ 로딩 상태 표시: loading 상태가 true일 때만 표시 */}
    {loading && <div>Loading...</div>}

    {/* 🛑 더 이상 데이터가 없을 때 표시: hasMore 상태가 false이고 로딩 중이 아닐 때 표시 */}
    {!hasMore && !loading && <div>더 이상 비디오가 없습니다.</div>}

    {/* 👇 ***** 이 부분이 추가되어야 합니다! ***** 👇 */}
    {/* 더 로드할 데이터가 있을 때만(hasMore가 true) 감시 타겟 요소를 렌더링 */}
    {hasMore && (
      <div
        ref={observerTarget} // useRef로 생성한 ref를 연결
        style={{ height: '50px', margin: '10px' /*, background: 'red'*/ }}>
        {/* 이 div가 화면에 보이면 IntersectionObserver 콜백이 실행됨 */}
        {/* 개발 중 확인을 위해 임시로 높이/배경색 지정 가능 */}
      </div>
    )}
  </>
);
}
export default VideoList

 

video생성 jsx

더보기
// VideoContainer.jsx
import React from 'react';
import useDisplay from './usedisplay';

//
//배열로 받고
const VideoContainer = ({ videos }) => {  // 중괄호로 videos를 받음
  const display = useDisplay();
    return (
      <>
        {videos.map((video) => {  // 배열을 map으로 순회
          const { ID, TITLE, IMG, CH, CHIMG, VIEWS, CATEGORY } = video;  // 각 비디오 객체의 속성을 구조분해
          const imagePath = `/assets/images/${IMG}.jpg`;
          return (
            <ytd-item-renderer key={ID} items-per-row={display} className={`video-item ${CATEGORY}`}>
                <div className="thumbnail-container">
                    <img src={imagePath} alt={TITLE} className="thumbnail" />
                </div>
                <div className="video-info">
                    <div className="channel-profile">
                        <img src={CHIMG} alt={CH} className="channel-img" />
                    </div>
                    <div className="video-details">
                        <h3 className="video-title">{TITLE}</h3>
                        <div className="channel-name">{CH}</div>
                        <div className="views-count">{VIEWS} 조회수</div>
                    </div>
                </div>
            </ytd-item-renderer>
          );
        })}
      </>
    );
  };

export default VideoContainer;

 

서버js

더보기
const pageConfig = require('./config/pageConfig');
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const a1 = pageConfig.PAGE_CONFIG.ITEMS_PER_PAGE;

const app = express();

// CORS 미들웨어 추가
app.use(cors());

// 데이터베이스 연결
const db = new sqlite3.Database('./videos.db', (err) => {
    if (err) {
        console.error('데이터베이스 연결 에러:', err);
    } else {
        console.log('데이터베이스 연결 성공');
        
        // 테이블 존재 여부 확인
        db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='videos'", [], (err, row) => {
            if (err) {
                console.error('테이블 확인 에러:', err);
            } else if (!row) {
                console.error('videos 테이블이 존재하지 않습니다!');
                // 테이블 생성
                db.run(`CREATE TABLE IF NOT EXISTS videos (
                    "ID"	TEXT,
                    "TITLE"	TEXT,
                    "IMG"	TEXT,
                    "CH"	TEXT,
                    "CHIMG"	TEXT,
                    "VIEWS"	INTEGER,
                    "CATEGORY"	TEXT
                )`, (err) => {
                    if (err) {
                        console.error('테이블 생성 에러:', err);
                    } else {
                        console.log('videos 테이블이 생성되었습니다');
                        // 샘플 데이터 삽입
                        const sampleData = [
                            ['이미지1.jpg', '첫 번째 비디오', '채널1', '100', '1일 전', '5:00', '프로필1.jpg'],
                            ['이미지2.jpg', '두 번째 비디오', '채널2', '200', '2일 전', '10:00', '프로필2.jpg'],
                            ['이미지3.jpg', '세 번째 비디오', '채널3', '300', '3일 전', '15:00', '프로필3.jpg']
                        ];
                        
                        const stmt = db.prepare(`INSERT INTO videos 
                            (ID, TITLE, IMG, CH, CHIMG, VIEWS, CATEGORY) 
                            VALUES (?, ?, ?, ?, ?, ?, ?)`);
                        
                        sampleData.forEach(data => {
                            stmt.run(...data);
                        });
                        
                        stmt.finalize();
                    }
                });
            } else {
                console.log('videos 테이블이 이미 존재합니다');
            }
        });
    }
});

app.get('/videos', (req, res) => {
    // 📌 클라이언트 요청에서 페이지 번호(page)와 페이지당 항목 수(limit) 파싱
    //    - `page`: 현재 조회하려는 페이지 번호. 기본값은 1.
    //    - `limit`: 한 페이지에 표시할 비디오의 수. 기본값은 12.
    const requestedPage = parseInt(req.query.page, 10);
    const requestedLimit = parseInt(req.query.limit, 10);

    // 유효하지 않은 값이거나 음수일 경우 기본값 사용
    const page = (isNaN(requestedPage) || requestedPage < 1) ? 1 : requestedPage;
    const limit = (isNaN(requestedLimit) || requestedLimit < 1) ? a1 : requestedLimit;
    // 📌 데이터베이스에서 건너뛸 항목 수(OFFSET) 계산
    //    - 예: 3페이지를 보고 싶고, 페이지당 10개씩 본다면 (3 - 1) * 10 = 20개의 항목을 건너뛴다.
    const offset = (page - 1) * limit;

    console.log(`[Backend] Received request: page=${req.query.page}, limit=${req.query.limit}`);
    console.log(`[Backend] Parsed values for SQL: Page=${page}, LIMIT=${limit}, OFFSET=${offset}`); // 페이지 정보 포함하여 로그 기록

    // 📌 전체 비디오 개수를 알기 위한 쿼리와 현재 페이지 데이터를 가져오는 쿼리 정의
    const countQuery = "SELECT COUNT(*) AS totalCount FROM videos";
    const dataQuery = "SELECT * FROM videos ORDER BY id ASC LIMIT ? OFFSET ?"; // 데이터 정렬 순서 추가 (예: 오름차순)

    // 💾 1. 전체 비디오 개수 조회
    db.get(countQuery, [], (err, countRow) => {
        if (err) {
            console.error('Total count 조회 에러:', err);
            return res.status(500).json({"error": "데이터베이스 조회 중 오류가 발생했습니다."});
        }

        const totalItems = countRow.totalCount;
        // 📌 총 페이지 수 계산 (올림 처리)
        const totalPages = Math.ceil(totalItems / limit);

        // 요청된 페이지가 실제 총 페이지 수를 초과하는 경우 빈 데이터를 반환하거나 마지막 페이지로 조정할 수 있음
        // 여기서는 빈 데이터를 반환하도록 처리됨 (OFFSET이 totalItems보다 크거나 같으면 data 쿼리 결과가 비게 됨)

        // 💾 2. 현재 페이지에 해당하는 비디오 데이터 조회
        db.all(dataQuery, [limit, offset], (err, rows) => {
            if (err) {
                console.error('Data 조회 에러:', err);
                return res.status(500).json({"error": "데이터베이스 조회 중 오류가 발생했습니다."});
            }

            console.log(`조회된 데이터 (Page: ${page}, Limit: ${limit}, Offset: ${offset}):`, rows.length, "개");

            // ✨ 클라이언트에 전달할 응답 객체 구성
            res.json({
                // 📊 페이지네이션 정보
                pagination: {
                    currentPage: page,      // 현재 페이지 번호
                    pageSize: limit,        // 페이지당 항목 수
                    totalItems: totalItems, // 전체 항목 수
                    totalPages: totalPages  // 전체 페이지 수
                },
                // 🎬 현재 페이지의 비디오 데이터
                data: rows
            });
        });
    });
});

// 테스트용 API
app.get('/test', (req, res) => {
    res.json({ message: '서버가 정상적으로 작동중입니다!' });
});

// 서버 시작
app.listen(3001, () => {
    console.log('서버가 3001 포트에서 실행중입니다');
});

// 에러 처리
process.on('uncaughtException', (err) => {
    console.error('처리되지 않은 예외:', err);
});

process.on('unhandledRejection', (err) => {
    console.error('처리되지 않은 Promise 거부:', err);
});

커스텀 요소

더보기
// 1. 가장 기본적인 커스텀 엘리먼트 클래스 정의
// 커스텀 엘리먼트 클래스 정의 (속성 처리 추가)
class Ytditemrenderer extends HTMLElement {
    constructor() {
      super(); // HTMLElement 생성자 호출
      this._itemsPerRow = null;
      this._isLockup = false;
    }
  
    // 1. 감시할 속성 이름들을 배열로 반환하는 정적 getter 정의
    static get observedAttributes() {
      // 'items-per-row' 와 'lockup' 속성의 변경을 감시하겠다고 선언
      return ['items-per-row', 'lockup'];
    }
  
    // 2. observedAttributes에 등록된 속성이 변경될 때 호출되는 콜백
    attributeChangedCallback(name, oldValue, newValue) {
      if (name === 'items-per-row') {
        const count = parseInt(newValue, 10); //10진수
        if (!isNaN(count)) {
          this._itemsPerRow = count;
        }
      } else if (name === 'lockup') {
        // lockup 속성 변경 처리 (속성의 존재 여부로 boolean 값 처리 가능)
        this._isLockup = newValue !== null; // 속성이 존재하면 true, 제거되면(newValue=null) false
      }
    }
  
    // 3. 요소가 DOM에 연결될 때 초기 속성값 처리 (선택 사항이지만 유용)
    connectedCallback() {
      console.log('ytd-item-renderer가 DOM에 연결됨');
  
      // 초기 'items-per-row' 값 확인 및 적용
      if (this.hasAttribute('items-per-row') && this._itemsPerRow === null) { // 아직 설정 안됐을 때만
        const initialValue = this.getAttribute('items-per-row');
        this.attributeChangedCallback('items-per-row', null, initialValue); // 변경 콜백 재활용
      }
      // 초기 'lockup' 값 확인 및 적용
      if (this.hasAttribute('lockup') && !this._isLockup) { // 아직 설정 안됐을 때만
        this.attributeChangedCallback('lockup', null, ""); // 새 값은 중요하지 않음(존재 여부만 체크)
      }
  
      // 여기에 초기 렌더링 또는 이벤트 리스너 설정 등 추가 로직 가능
    }
    disconnectedCallback() {
      console.log('ytd-item-renderer가 DOM에서 제거됨');
    }
  }
  
  // 커스텀 엘리먼트 등록
  if (!customElements.get('ytd-item-renderer')) {
    customElements.define('ytd-item-renderer', Ytditemrenderer);
  }

 

 

app.css

더보기
#root {
  margin: 0 auto;
  padding: 2rem;

}

.video-item {
  width: 100%;
  margin-bottom: 20px;
  cursor: pointer;
  
}

.thumbnail-container {
  width: 100%;
  position: relative;
}

.thumbnail {
  width: 100%;
  border-radius: 8px;
}

.video-info {
  display: flex;
  padding-top: 12px;
  gap: 12px;
}

.channel-profile {
  width: 36px;
  height: 36px;
  flex-shrink: 0;
}

.channel-img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
}

.video-details {
  flex: 1;
}

.video-title {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 4px;
  line-height: 1.4;
}

.channel-name {
  font-size: 12px;
  color: #606060;
  margin-bottom: 2px;
}

.views-count {
  font-size: 12px;
  color: #606060;
}


.grid-container {
  display: grid;
  gap: 20px;
  
  /* 모바일: 2개 */
  grid-template-columns: repeat(2, 1fr);
}

/* 태블릿: 3개 */
@media (min-width: 768px) {
  .grid-container {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* 작은 데스크탑: 4개 */
@media (min-width: 1024px) {
  .grid-container {
    grid-template-columns: repeat(4, 1fr);
  }
}

/* 큰 데스크탑: 6개 */
@media (min-width: 1440px) {
  .grid-container {
    grid-template-columns: repeat(6, 1fr);
  }
}