본문 바로가기
카카오x구름 풀스택/프로젝트

[Youtube-clone 팀 프로젝트] React 활용한 유튜브 "메인 화면" 제작 및 회고 - 1

by 꾸주니=^= 2025. 1. 9.

깃허브 주소

https://github.com/PrograMemo-Groom/youtube-clone

 

GitHub - PrograMemo-Groom/youtube-clone: [Team 1] prograMemo youtube-clone site

[Team 1] prograMemo youtube-clone site. Contribute to PrograMemo-Groom/youtube-clone development by creating an account on GitHub.

github.com

 

배포사이트 주소

https://programemo-youtube.netlify.app/

 

PrograMemo Youtube

 

programemo-youtube.netlify.app

 


프로그래모 스터디에서 처음으로 팀 프로젝트인 'youtube-clone'을 시작했습니다 !
제가 맡은 부분은 메인 화면사이드바 제작이었고 유튜브 api를 연결하여 완성했습니다

 

초기 기획서

https://github.com/PrograMemo-Groom/youtube-clone/issues/15

 

📝 동영상 메인페이지 기획 · Issue #15 · PrograMemo-Groom/youtube-clone

메인페이지 구조 메인 화면 카테고리 영역 사이드바 1. 메인화면 구성 - 광고 - 영상 -> 보통 광고 배너 1, 영상 ∞..(영상 중간에 광고 포함) 1.1. 광고 메인화면 맨 앞에 배치된 광고 형태 영상 중간

github.com

 

초기 기획서는 다음과 같이 거창하게 잡았지만.. 유튜브 api 연결해서 영상 띄우는 걸로 만족하기로 했습니다 🥲

메인화면의 구성사이드바, 카테고리, 메인영상을 나타내는 화면 부분입니다

메인화면 무한 스크롤 기능을 추가해, 페이지 하단 부분에 스크롤이 닿으면 새로운 영상들이 렌더링되게 구현해야했습니다.
이 과정에서 어려움을 많이 겪었고, Intersection Observer, threshold, 스크롤 위치를 통해 무한스크롤 기능을 구현했습니다.

 

메인화면에는 무한 스크롤 외 다양한 기능들이 존재합니다.

  • grid 레이아웃을 활용한 반응형 디자인미디어 쿼리
  • iframe을 활용한 동영상 미리보기 기능
  • 동영상 정보와 인터랙션 - 채널 정보와 클릭 이벤트 & 동영상 제목과 저자 링크
  • Dropdown Menu(더보기 메뉴)
  • 영상 시간 표시 기능
  • 텍스트 스타일링 및 줄 수 제한

 

이제부터 하나씩 코드를 살펴보도록 하겠습니다 !

 


메인화면 전체 코드

Main.js

import React, {useState} from 'react';
import CategoryBar from "./category/CategoryBar";
import MainVideos from "./videos/MainVideos";


const Main = () => {
    const [fetchFunction, setFetchFunction] = useState(null);

    const handleCategoryChange = (fetchFunctionName) => {
        setFetchFunction(fetchFunctionName);
    };

    return (
        <div>
            <CategoryBar onCategoryChange={handleCategoryChange} />
            <MainVideos fetchFunction={fetchFunction} />
        </div>
    );
};

export default Main;

Main.js에는 카테고리 바와 메인영상을 나타내고 있습니다.
카테고리바에 대해서는 다음 포스터에 작성하도록 하겠습니당 😉

 

이번 포스터에는 MainVideo.jsMainVideo.module.css에 대해 자세히 알아보겠습니다 ~!!

MainVideos.js

import React, { useEffect, useState, useRef } from "react";
import styles from "./MainVideos.module.css";
import useNavigation from "../../../hooks/useNavigation";
import { useSelector, useDispatch } from "react-redux";
import {fetchVideos} from "../../../store/reducer/MainReducer";
import DropdownMenu from "../../dropdownMenu/DropdownMenu";
const MainVideos = ({ fetchFunction }) => {
    const scrollPositionRef = useRef(0); // 스크롤 위치 추적

    const [hoveredVideo, setHoveredVideo] = useState(null); // 현재 호버 중인 비디오 ID
    const [openDropdown, setOpenDropdown] = useState(null); // 더보기 메뉴
    const { link } = useNavigation();
    const observerRef = useRef(null); // Intersection Observer를 위한 ref

    const dispatch = useDispatch();
    const { videoList, loading, error, nextPageToken } = useSelector((state) => state.videos);

    // 컴포넌트 내부의 fetchVideos 함수 제거하고, Redux Thunk를 사용
    useEffect(() => {
        dispatch({ type: "videos/reset" }); // Redux 상태 초기화 액션 디스패치
        dispatch(fetchVideos({ categoryId: fetchFunction }));
    }, [dispatch, fetchFunction]);

    // Intersection Observer로 무한 스크롤 구현
    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                if (entries[0].isIntersecting && nextPageToken && !loading) {
                    scrollPositionRef.current = window.scrollY; // 현재 스크롤 위치 저장
                    dispatch(fetchVideos({ categoryId: fetchFunction, pageToken: nextPageToken }));
                }
            },
            { threshold: 0.1 }
        );

        const currentRef = observerRef.current;
        if (currentRef) observer.observe(currentRef);

        return () => {
            if (currentRef) observer.unobserve(currentRef);
        };
    }, [dispatch, nextPageToken, loading, fetchFunction]);

    // 스크롤 위치 복원
    useEffect(() => {
        if (!loading) {
            window.scrollTo(0, scrollPositionRef.current); // 이전 스크롤 위치로 복원
        }
    }, [videoList, loading]); // videoList가 업데이트될 때 실행

    const handleShowVideo = (videoId, event) => {
        if (event) event.stopPropagation(); // 이벤트 버블링 방지
        link(`/detail?q=${videoId}`);
    };

    const handleChannelClick = (channelId, event) => {
        if (event) event.stopPropagation(); // 이벤트 버블링 방지
        window.location.href = `https://www.youtube.com/channel/${channelId}`;
    };

    const toggleDropdown = (videoId) => {
        setOpenDropdown((prev) => (prev === videoId ? null : videoId));
    };

    if (loading) {
        return <div className={styles.loading}>로딩 중...</div>;
    }

    if (error) {
        return <div className={styles.error}>{error}</div>;
    }

    return (
        <>
            <div className={styles.videoGrid}>
                {videoList.map((video, index) => (
                    <div
                        key={index}
                        className={styles.videoPreview}
                        onMouseEnter={() => setHoveredVideo(video.videoId)}
                        onMouseLeave={() => setHoveredVideo(null)}
                    >
                        <div className={styles.thumbnailRow}>
                            {hoveredVideo === video.videoId ? (
                                <iframe
                                    className={styles.videoPlayer}
                                    src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1`}
                                    title={video.title}
                                    allow="autoplay; encrypted-media"
                                    allowFullScreen
                                ></iframe>
                            ) : (
                                <img
                                    className={styles.thumbnail}
                                    alt={video.title}
                                    src={video.thumbnail}
                                />
                            )}
                            <div className={styles.videoTime}>{video.time}</div>
                        </div>
                        <div className={styles.videoInfoGrid}>
                            <div className={styles.channelPicture}>
                                <img
                                    className={styles.profilePicture}
                                    alt="channel profile"
                                    src={video.profile}
                                    onClick={(event) => handleChannelClick(video.channelId, event)} // 유저 프로필 클릭 이벤트 추가
                                />
                            </div>
                            <div className={styles.videoInfo}>
                                <div className={styles.titleRow}>
                                    <p
                                        className={styles.videoTitle}
                                        onClick={(event) => handleShowVideo(video.videoId, event)}
                                    >
                                        {video.title}
                                    </p>
                                    <div style={{position: "relative"}}>
                                        <img
                                            src={`${process.env.PUBLIC_URL}/assets/icon/more_btn_black.svg`}
                                            alt="more"
                                            className={styles.more}
                                            onClick={() => toggleDropdown(video.videoId)}
                                        />
                                        {openDropdown === video.videoId && (
                                            <DropdownMenu />
                                        )}
                                    </div>
                                </div>
                                <p
                                    className={styles.videoAuthor}
                                    onClick={(event) => handleShowVideo(video.videoId, event)}
                                >
                                    {video.author}
                                </p>
                                <p
                                    className={styles.videoStats}
                                    onClick={(event) => handleShowVideo(video.videoId, event)}
                                >
                                    {video.stats}
                                </p>
                            </div>
                        </div>
                    </div>
                ))}
            </div>
            {loading && <div className={styles.loading}>로딩 중...</div>}
            <div ref={observerRef} style={{ height: "1px", background: "transparent"  }}></div>
        </>
    );
};

export default MainVideos;

 

MainVideos.module.css

.videoGrid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    margin: 0 auto;
    max-width: 1920px;
    padding-top: 10px;
    padding-left: 20px;
    width: 100%;
    box-sizing: border-box;

}

@media (max-width: 700px) {
    .videoGrid {
        grid-template-columns: 1fr;
    }
}

@media (max-width: 1100px) and (min-width: 701px) {
    .videoGrid {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (min-width: 1101px) and (max-width: 1420px) {
    .videoGrid {
        grid-template-columns: repeat(3, 1fr);
    }
}

@media (min-width: 1421px) {
    .videoGrid {
        grid-template-columns: repeat(4, 1fr);
    }
}

.thumbnailRow {
    position: relative;
    width: 100%;
    aspect-ratio: 16 / 9 !important; /* 비율 유지 */
    background-color: #000;
    border-radius: 12px;
    cursor: pointer;
    overflow: hidden;
}
.thumbnail,
.videoPlayer {
    position: absolute;
    width: 100%;
    height: 100%;
    object-fit: cover; /* 썸네일 비율 유지 */
    transition: opacity 0.3s ease-in-out;
    border-radius: 12px;
    cursor: pointer;
    top: 0;
    left: 0;
}

.thumbnailRow:hover .thumbnail {
    opacity: 0;
}

.videoPlayer {
    z-index: 1;
    opacity: 0;
    transition: opacity 0.3s ease-in-out;
}

.thumbnailRow:hover .videoPlayer {
    opacity: 1;
}

.videoTime {
    position: absolute;
    bottom: 8px;
    right: 5px;
    font-size: 12px;
    font-weight: 500;
    padding: 4px;
    background-color: rgba(0, 0, 0, 0.65); /* 검정색, 투명도 65% */
    border-radius: 10%;
    color: white;
}

.videoInfoGrid {
    display: grid;
    padding: 10px;
    grid-template-columns: 50px 1fr;
    gap: 5px;
}

.profilePicture {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    cursor: pointer;
}

.videoAuthor {
    margin-bottom: 4px;
    cursor: pointer;
}

.videoTitle {
    flex: 1;
    word-wrap: break-word;
    margin-right: 8px;
    cursor: pointer;
    font-size: 15px;
    margin-bottom: 5px;
    overflow: hidden;
    text-overflow: ellipsis; /* 생략 부호 추가 */
    display: -webkit-box; /* 플렉스 박스 사용 */
    -webkit-line-clamp: 2; /* 최대 2줄까지만 표시 */
    -webkit-box-orient: vertical; /* 박스 방향 설정 */
    line-height: 1.5; /* 줄 간격을 늘림 */
}

.videoAuthor,
.videoStats {
    font-size: 14px;
    color: #858585;
    cursor: pointer;
}

.titleRow {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    position: relative;

}

.more {
    width: 30px;
    height: 30px;
    flex-shrink: 0;
    margin-left: 8px;
    cursor: pointer;
    top: 0;
    right: 0;
}

.videoPreview {
    display: flex;
    flex-direction: column;
    justify-self: center;
    width: 95%;
    border-radius: 12px;
    margin-bottom: 10px;
    cursor: pointer;
}

 


무한 스크롤 구현 방법

어떻게 무한 스크롤을 구현했는지 단계별로 설명하겠습니다.

1. 프로젝트 구조

  • 무한 스크롤을 구현한 주요 컴포넌트는 MainVideos입니다.
  • Redux를 활용하여 상태 관리(videoList, loading, nextPageToken)를 처리합니다.
  • Intersection Observer API로 화면 끝에 도달할 때 데이터를 추가로 불러옵니다.

 

2. 무한 스크롤 구현 방법

1) Intersection Observer 사용
Intersection Observer API는 특정 요소가 뷰포트에 들어오거나 나갈 때 콜백을 실행할 수 있게 해줍니다. 이를 활용해 화면 하단에 도달했을 때 새 데이터를 요청할 수 있습니다.

useEffect(() => {
    const observer = new IntersectionObserver(
        (entries) => {
            if (entries[0].isIntersecting && nextPageToken && !loading) {
                scrollPositionRef.current = window.scrollY; // 현재 스크롤 위치 저장
                dispatch(fetchVideos({ categoryId: fetchFunction, pageToken: nextPageToken }));
            }
        },
        { threshold: 0.1 } // 요소가 10% 이상 화면에 보일 때 트리거
    );

    const currentRef = observerRef.current;
    if (currentRef) observer.observe(currentRef);

    return () => {
        if (currentRef) observer.unobserve(currentRef);
    };
}, [dispatch, nextPageToken, loading, fetchFunction]);

작동 방식:

  • observerRef는 화면 하단의 감시할 요소를 참조합니다.
  • entries[0].isIntersecting는 감시 중인 요소가 뷰포트에 들어왔는지 확인합니다.
  • 조건(nextPageToken, loading)을 만족하면 다음 데이터를 불러오도록 dispatch(fetchVideos)를 호출합니다.

 

2) Redux를 활용한 데이터 관리
데이터를 요청하고 관리하기 위해 Redux Thunk를 사용합니다.

  • fetchVideos: 비동기 액션 크리에이터로, Redux 상태에 새 데이터를 추가합니다.
  • Redux 상태 (videoList, loading, nextPageToken)는 컴포넌트에서 동적으로 활용됩니다.
const { videoList, loading, error, nextPageToken } = useSelector((state) => state.videos);

 

3) 스크롤 복원 기능
무한 스크롤에서는 데이터를 추가로 불러온 후 이전 스크롤 위치를 유지하는 것이 중요합니다.

useEffect(() => {
    if (!loading) {
        window.scrollTo(0, scrollPositionRef.current); // 이전 스크롤 위치로 복원
    }
}, [videoList, loading]); // videoList가 업데이트될 때 실행

작동 방식:

  • 새 데이터를 불러온 후 window.scrollTo로 이전 스크롤 위치로 복원합니다.
  • scrollPositionRef는 이전 스크롤 위치를 저장하는 데 사용됩니다.

 

4) 사용자 인터페이스
데이터 로딩 중에는 "로딩 중..." 메시지를 표시하고, 에러가 발생하면 에러 메시지를 보여줍니다.

if (loading) {
    return <div className={styles.loading}>로딩 중...</div>;
}

if (error) {
    return <div className={styles.error}>{error}</div>;
}

 

3. 결과 화면

  1. 사용자가 페이지를 아래로 스크롤하면 observerRef가 화면에 나타납니다.
  2. Intersection Observer는 이 이벤트를 감지하여 추가 데이터를 요청합니다.
  3. 데이터가 로딩되는 동안 "로딩 중..." 메시지가 표시됩니다.
  4. 데이터 로딩이 완료되면 화면에 새 데이터가 추가됩니다.

 

4. 구현의 핵심 포인트

  1. Intersection Observer API를 사용해 뷰포트의 끝을 감지
  2. Redux로 상태 관리 및 비동기 데이터 로드
  3. 이전 스크롤 위치 복원으로 사용자 경험 향상

무한 스크롤 외, 주요 기능들

이번에는 무한스크롤 외에 구현된 주요 기능들을 살펴보도록 하겠습니다.

 

1. 반응형 디자인

1) Grid 레이아웃 활용
.videoGrid 클래스를 통해 CSS Grid를 사용하여 동영상 썸네일을 정렬합니다. 화면 크기에 따라 동영상이 적절히 배치되며, 레이아웃이 깨지지 않도록 설정되었습니다.

grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  • auto-fit: 가능한 많은 동영상을 화면에 배치.
  • minmax(200px, 1fr): 최소 너비 200px, 최대 화면 크기에 따라 유동적으로 조절.

 

2) 반응형 미디어 쿼리
다양한 화면 크기를 고려한 미디어 쿼리를 통해 적응형 디자인을 구현합니다.

@media (max-width: 700px) {
    .videoGrid {
        grid-template-columns: 1fr;
    }
}
  • 700px 이하: 1열 레이아웃.
  • 701px ~ 1100px: 2열 레이아웃.
  • 1101px ~ 1420px: 3열 레이아웃.
  • 1421px 이상: 4열 레이아웃.

이로 인해 모바일, 태블릿, 데스크톱 환경에서도 일관된 UI를 제공합니다.

 

2. 동영상 미리보기 기능

1) 썸네일과 동영상 플레이어 전환
사용자가 썸네일 위로 마우스를 올리면 동영상이 자동 재생되도록 설정되어 있습니다. 이는 CSS와 iframe을 활용하여 구현되었습니다.

.thumbnailRow:hover .thumbnail {
    opacity: 0;
}

.thumbnailRow:hover .videoPlayer {
    opacity: 1;
}
  • 기본 상태: 동영상 썸네일(.thumbnail)이 표시.
  • 호버 상태: 썸네일이 투명해지고, 동영상 플레이어(.videoPlayer)가 나타납니다.
  • 부드러운 전환: transition: opacity 0.3s ease-in-out으로 자연스러운 애니메이션 효과.

 

3. 동영상 정보와 인터랙션

1) 채널 정보와 클릭 이벤트
각 동영상의 채널 프로필 이미지를 클릭하면 해당 채널의 YouTube 페이지로 이동합니다. 이 인터랙션은 사용자가 쉽게 채널 정보를 탐색할 수 있도록 돕습니다.

const handleChannelClick = (channelId, event) => {
    if (event) event.stopPropagation();
    window.location.href = `https://www.youtube.com/channel/${channelId}`;
};

 

2) 동영상 제목과 저자 링크

  • 제목과 저자를 클릭하면 상세 페이지로 이동합니다.
  • 이를 통해 특정 동영상의 세부 정보를 확인할 수 있습니다.

 

4. 더보기(Dropdown Menu) 기능

1) 드롭다운 메뉴
동영상 카드의 오른쪽 상단에 "더보기" 버튼이 있습니다. 사용자가 이 버튼을 클릭하면 드롭다운 메뉴가 열리며, 추가 옵션을 제공합니다.

const toggleDropdown = (videoId) => {
    setOpenDropdown((prev) => (prev === videoId ? null : videoId));
};
  • 클릭으로 메뉴 열기/닫기: openDropdown 상태를 활용.
  • 드롭다운 메뉴 컴포넌트(<DropdownMenu />)는 선택적으로 렌더링됩니다.

 

5. 영상 시간 표시 기능

썸네일의 오른쪽 하단에는 영상 재생 시간을 표시하여, 사용자가 동영상 길이를 미리 확인할 수 있습니다.

.videoTime {
    position: absolute;
    bottom: 8px;
    right: 5px;
    font-size: 12px;
    background-color: rgba(0, 0, 0, 0.65);
    color: white;
    border-radius: 10%;
}
  • 투명도 있는 배경: rgba(0, 0, 0, 0.65)로 가독성 향상.
  • 크기와 위치 최적화: 작은 크기와 하단 배치로 UI 깔끔하게 유지.

 

6. 텍스트 스타일링 및 줄 수 제한

1) 제목
동영상 제목은 한 줄로 표시되지 않고, 최대 두 줄까지만 보이도록 설정됩니다. 긴 제목은 생략 부호(...)로 처리됩니다.

.videoTitle {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    text-overflow: ellipsis;
    overflow: hidden;
}
  • -webkit-line-clamp: 텍스트 줄 수 제한.
  • text-overflow: ellipsis: 길이를 초과하는 텍스트를 생략 부호로 표시.

 

2) 저자 및 조회수
저자와 조회수는 보조 정보로서 작은 글씨로 표시되며, 클릭 가능한 요소로 설정됩니다.

.videoAuthor,
.videoStats {
    font-size: 14px;
    color: #858585;
    cursor: pointer;
}

 

주요 기능들이 포함된 모습을 영상으로 확인해보겠습니다 ~

 


이처럼 오늘은 Youtube-clone 팀 프로젝트에서 제가 맡은 "메인화면"의 기능인 무한스크롤과 그외 기능에 대해 알아봤습니다.
메인화면의 Redux 적용, 카테고리바, 드롭다운 메뉴에 대해서는 다음 포스터에서 알아보도록 하겠습니다 ~