구름에서 진행한 두 번째 팀 프로젝트 ☁️
넷플릭스 클론 코딩이지만 이번에도 넷플릭스를 똑같이 클론하지는 않았다.
우리 팀의 이름인 홍삼의 홍을 따와 Hongflix를 만들었고 메인 컬러는 남색을 사용했다.
영화 사이트 클론 코딩은 비교적 흔한 레퍼런스이기도 하고, 존 안 코치님의 강의에서도 들었어서 익숙했다.
다만 위 경우는 외부 API를 가져오는 거였고, 우리는 관리자로 고도화 해서 콘텐츠 DB를 CRUD 했다.
그래서 이렇게 생성된 DB에서 정보를 가져오는 구조였지만 그래도 기본 틀은 비슷해서 곧잘 할 수 있었다.
다만 이번 프로젝트의 챌린지는 동영상 재생 & 기간 내 다양한 페이지 구현이었다. 🥲
자세한 부분은 각 파트로 나눠 서술할 예정이다.
TO DO
- 홈 화면
- 유저
- 회원가입/로그인
- 마이페이지 화면
- 구독 결제 기능
- 최근 관람 콘텐츠 조회
- 검색 화면에서 콘텐츠 검색
- 카테고리 화면에서 장르별 콘텐츠 조회
- 콘텐츠 포스터 클릭 시 정보를 담은 모달
- 모달 내 보러 가기 버튼 클릭 시 로그인 & 구독 여부에 따라 회차 정보 노출
- 회차 정보의 썸네일 클릭 시 영상 재생
- 관리자
- 회원가입/로그인
- 콘텐츠 CRUD
- 콘텐츠에 대한 회차 정보 CRUD
볼드 처리한 부분 + Figma 작업이 내가 담당한 부분이다.
HongsamSNS 때와는 비교도 되지 않는 (ㅋㅋ) 많은 기능들을 구현했다.
전에 내가 로그인/회원가입을 했어서 이번에는 같은 프론트인 갱갱규 님이 해당 파트를 맡아주셨다.
그렇다 보니 전반적으로 어드민과 관련된 것은 갱갱규 님이, 유저와 관련된 것은 모두 내가 맡아 작업했다.
기술 스택
- FE: React, Tailwind
- BE: Java Spring, Mybatis
- Database: Mysql
- Devops: Docker, Jenkins, AWS
- Communication: Github, Slack, Discode, Notion
이전 프로젝트에서는 부트스트랩을 사용했어서, 이번엔 비슷한 Tailwind를 써봤다.
사실 두 프레임워크의 차이는 잘 모르겠는데 개취로 나는 Tailwind가 더 편했다.
초기 기획
야심차게 반응형으로 만들려고 했지만 역량 부족으로 PC만 지원이 가능하다.
미스컴으로 작업 도중에 회차 정보 화면이 추가되어 이 부분은 Figma 작업을 건너 뛰었다. 🥲
Figma와 실제 프로젝트 결과물은 약간의 차이가 있다.
- 홈 화면
- Header 상단 / Footer 하단에 붙여두기
- 스크롤 시 상단 Header 색 전환
- 로그인 시 로그인 버튼이 마이페이지로 변경
- 캐러샐을 넘기면 다른 콘텐츠 이미지/제목을 보여주는 배너로 변경
- 유저가 로그인 & 구독한 경우, 가장 최근 시청한 콘텐츠 캐러셀로 보여주기
- 작품 DB의 생성일 기준으로 정렬해 신작 콘텐츠 캐러셀로 보여주기
- 작품 DB에서 스릴러/미스터리 장르의 작품만 필터해 장르 콘텐츠 캐러셀로 보여주기
- 콘텐츠 포스터 클릭 시 정보를 담은 모달 노출
- 카테고리 화면
- 작품 DB에서 로맨스 장르의 작품만 필터해 장르 콘텐츠 캐러셀로 보여주기
- 작품 DB에서 판타지/SF 장르의 작품만 필터해 장르 콘텐츠 캐러셀로 보여주기
- 작품 DB에서 일상 장르의 작품만 필터해 장르 콘텐츠 캐러셀로 보여주기
- 콘텐츠 포스터 클릭 시 정보를 담은 모달 노출
- 검색 화면
- 찾고 싶은 작품 제목을 input에 입력하면 useDebounce로 실시간 검색
- 조회된 콘텐츠 포스터 및 hover 시 작품 제목 노출
- 콘텐츠 포스터 클릭 시 정보를 담은 모달 노출
- 콘텐츠 모달
- 모달 내 클릭한 콘텐츠의 정보 보여주기 (제목, 장르, 공개일, 줄거리)
- 지금 바로 보러 가기 클릭 시 로그인 & 구독 여부를 확인해 회차 정보 화면으로 이동
- 작품 DB에 등록된 작품 중 회차 정보도 등록된 작품이라면 에피소드 별 썸네일 노출, 클릭 시 영상 재생
- 작품 DB에 등록된 작품 중 회차 정보가 등록되지 않은 작품이라면 얼럿과 함께 뒤로 가기
- 마이페이지
- 회원 정보 조회
- 구독 결제
홈 화면
먼저 Header, Footer는 전역으로 사용되는 컴포넌트라 App.js에서 상위 컴포넌트로 감싸줬다.
따라서 실질적인 홈 화면에는 Banner, Main(하단에 노출되는 작품 3종류)를 감싼 Home 컴포넌트가 노출된다.
Footer는 진짜 div로만 아주 간단하게 만들었는데, 하단에 고정시키기 위해 Footer 외의 모든 컴포넌트를 묶어 flex-1을 주었다.
스크롤 시 상단 Header 색 전환
Header의 경우 스크롤을 하면 배경 색이 하얀색에서 남색으로 바뀌게 이벤트를 추가했다.
스크롤 이벤트가 발생하면 handleScroll 함수가 동작하고, 세로가 5보다 커지면 isScrolled가 true로 바뀐다.
컴포넌트가 언마운트 될 때는 이벤트 리스너를 제거해주는 것이 좋다.
그렇지 않으면 컴포넌트가 언마운트 될 때에도 이벤트가 계속 활성화 되어 있어 메모리 누수가 생기기 때문이다.
따라서 컴포넌트가 마운트 될 때 스크롤 이벤트를 추가, handleScroll을 동작 시키고, 언마운트 될 때는 제거를 해준다.
// 스크롤 시 헤더 배경색 변경
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 5) {
setIsScrolled(true);
} else {
setIsScrolled(false);
}
};
// 스크롤 이벤트 리스너 추가
window.addEventListener('scroll', handleScroll);
// 컴포넌트 언마운트 시 스크롤 이벤트 리스너 제거
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
isScrolled가 true일 경우 삼항연산자로 조건부 렌더링을 해준다.
색이 변하는 것은 transition을 줘서 스무스하게 바뀌도록 한다.
<header
className={`p-4 sticky top-0 z-50 ${
isScrolled ? 'bg-indigo-950' : 'bg-transparent'
}`}
style={{
borderBottom: isScrolled ? 'none' : '1px solid black',
transition: 'background-color 0.3s ease-in',
}}
>
로그인 시 로그인 버튼이 마이페이지로 변경
로그인 하지 않은 상태라면 상단 Header에 로그인 버튼이 뜨고, 로그인을 했다면 마이페이지 버튼이 뜬다.
위와 비슷하게 isLoggedIn 으로 조건부 렌더링을 하고 Link로 각 버튼에 맞는 컴포넌트를 열어준다.
왜 여긴 또 nav가 되었는지... 모르겠네... 🙂
<nav className='space-x-4'>
{isLoggedIn ? (
<>
<Link
to='/mypage'
className={`text-sm ${
isScrolled ? 'text-white' : 'text-indigo-950'
}`}
>
마이 페이지
</Link>
<Link
onClick={(e) => {
isLogout(e, '/');
}}
className={`text-sm ${
isScrolled ? 'text-white' : 'text-indigo-950'
}`}
>
로그아웃
</Link>
</>
) : (
<>
<Link
to='/login'
className={`text-sm ${
isScrolled ? 'text-white' : 'text-indigo-950'
}`}
>
로그인
</Link>
</>
)}
</nav>
또한 로그인 시 userInfo 객체를 전달 받으므로 해당 객체에서 필요한 정보를 파싱해서 사용하면 된다.
캐러샐을 넘기면 다른 콘텐츠 이미지/제목을 보여주는 배너로 변경
배너는 slick이라는 라이브러리를 사용했다.
근데 이거는 화살표라든지 dot을 직접 커스텀해야 하는? 약간의 불편함이 있었다.
유저 마음대로 작업할 수 있는 게 편리할 수도 있지만... 나는 초보자라 잘 차려진 밥상이 필요해서 꽤 헤맸다.
또 이미지 크기에 따라서 화면이 자꾸 들쑥날쑥해서 ㅠㅠ 하나를 맞추면 하나가 강제로 확대되고...
힘들어서 어쩔 수 없이 이미지가 그나마 비슷한 2가지만 사용했더니 캐러셀이 다채롭지 않아 아쉽다.
기본적으로는 아래와 같이 Slider 태그로 감싸줘야 한다.
이 아래에 Slide가 들어가고, 이미지 소스랑 해당 이미지의 제목을 넣어준다.
<div>
<Slider {...settings}>
{slides.map((slide, index) => (
<div key={index}>
<Slide imageSrc={slide.imageSrc} text={slide.text} />
</div>
))}
</Slider>
</div>
Slide는 slides 배열을 map으로 전개해 꺼낸 것을 넣는다.
대문자 소문자로 구분해서 가독성이 별로 좋지 않은 것 같다.
이 때 배너에 사용할 이미지는 내 폴더에만 있어서 상대 경로로 접근했다.
따라서 process.env.PUBLIC_URL로 시작한 뒤 이미지 파일이 있는 경로를 적어줬다.
const slides = [
{ imageSrc: `${process.env.PUBLIC_URL}/img/b1.jpg`, text: '최애의 아이' },
{ imageSrc: `${process.env.PUBLIC_URL}/img/b2.jpg`, text: '주술회전' },
];
세팅은 아래와 같이 설정할 수 있다.
나는 이미지의 점과 무한 반복, 자동 속도 2s, 슬라이드 하나 당 1개의 화면, 1개씩 넘길 수 있게 했다.
또한 다음 화살표, 이전 화살표도 컴포넌트로 만들어줬다.
const settings = {
dots: true,
infinite: true,
speed: 2000,
slidesToShow: 1,
slidesToScroll: 1,
nextArrow: <NextArrow />,
prevArrow: <PrevArrow />,
};
각 화살표는 onClick 이벤트를 props로 받는다.
// 오른쪽 화살표
const NextArrow = ({ onClick }) => (
<button
className="absolute right-2.5 top-1/2 text-4xl font-bold text-black -translate-y-2/4 z-10"
onClick={onClick}
type="button"
>
{'>'}
</button>
);
// 왼쪽 화살표
const PrevArrow = ({ onClick }) => (
<button
className="absolute left-2.5 top-1/2 text-4xl font-bold text-black -translate-y-2/4 z-10"
onClick={onClick}
type="button"
>
{'<'}
</button>
);
Slide는 이미지 소스랑 해당 이미지의 제목을 props로 받는다.
이 때 해당 이미지의 제목을 이미지 하단 가운데에 정렬할 거라 부모 div를 relative, 제목 div를 absolute로 설정해준다.
const Slide = ({ imageSrc, text }) => (
<div className="relative">
<img className="w-full" src={imageSrc} alt="carousel" />
<div className="absolute -translate-x-2/4 px-5 py-1.5 rounded-3xl bottom-5 left-2/4 text-xs md:text-base lg:text-sm text-center text-white bg-indigo-950">
{text}
</div>
</div>
);
즉 slick 라이브러리에서 쓸 슬라이드, 세팅, 양쪽 화살표, 슬라이드로 전개할 내용을 정해주면 된다.
이런 캐러셀 라이브러리를 많이 사용했는데 기본적으로 이 라이브러리를 제대로 이해하고 쓰지 못한 것 같아 아쉽다.
유저가 로그인 & 구독한 경우, 가장 최근 시청한 콘텐츠 캐러셀로 보여주기
단순 콘텐츠 노출은 로그인, 구독 여부가 필요하지 않다.
하지만 최근 시청한 콘텐츠 캐러셀은 유저 정보가 있어야 하고, 시청했으니 구독을 해야 한다.
따라서 로그인 여부는 isLogined, 구독 여부는 userInfo의 available을 체크해 조건부 렌더링을 했다.
{isLogined && userInfo['available'] !== 0 ? (
<div className="flex flex-col gap-1 mb-8">
<div className="font-bold text-base mt-3 md:text-lg lg:text-xl">
최근 시청한 콘텐츠
</div>
<Slider {...settings}>
{slide1.map((slide) => (
<div key={slide.id}>
<Slide
id={slide.id}
imageSrc={slide.imageSrc}
title={slide.title}
genre={slide.genre}
createdDate={slide.createdDate}
explanation={slide.explanation}
/>
</div>
))}
</Slider>
</div>
) : null}
후술할 다른 콘텐츠 캐러셀에서도 Slick을 썼기 때문에 Slick과 관련된 것은 하나로 정리했다.
이후 각각 캐러셀에서 필요한 정보를 가져와 사용하도록 했다.
모달을 전역으로 쓰지 않아서 코드가 매우 매우 지저분하고 스스로도 알아보기가 힘들다... 🥲
우선은 아래와 같이 slideData에 유저가 가장 최근에 시청한 콘텐츠 목록을 받아온다.
이에 대한 id, 이미지 소스와 타이틀, 장르 등등 작품 DB에 입력한 정보들을 받아온다.
줄거리의 경우 238자보다 길 경우 잘라서 ...으로 붙여주고 짧을 경우 그대로 보여준다.
또한 콘텐츠가 많을 경우 8개만 보여주도록 잘라주고 그렇게 자른 CutData를 setSlide1에 넣어준다.
setSlide1은 유저의 최신 시청 콘텐츠 목록이고, 2는 신작, 3은 스릴러 장르다.
useEffect(() => {
axios
.get(
$`{serverURL/contents/latest}`
)
.then((response) => {
const slideData = response.data.map((item) => ({
id: item.id,
imageSrc: item.accessKey,
title: item.title,
genre: item.genre,
createdDate: item.createdDate,
explanation:
item.explanation.length > 238
? item.explanation.slice(0, 238) + '...'
: item.explanation,
}));
const CutData = slideData.slice(0, 8);
setSlide1(CutData);
})
.catch((error) => {
console.error('로그인/구독없이 최신작품 조회시', error);
});
}, []);
캐러셀의 각 이미지들은 클릭하면 모달이 오픈된다. 이 때 모달에도 작품 DB에서 불러온 정보들을 내려준다.
그러면 후술할 모달에 클릭한 작품의 정보가 잘 나오게 된다.
이외에 기본 Slick 틀이나 화살표는 배너 캐러셀과 동일하다.
다른 점이 있다면 settings가 약간 다르다.
아래와 같이 한 번에 5개의 이미지를 보여주는 게 기본이지만, 가로가 376px보다 작으면 홈 배너처럼 한 번에 1개의 이미지만 보여준다.
const settings = {
dots: true,
infinite: true,
speed: 1000,
slidesToShow: 5,
slidesToScroll: 1,
nextArrow: <NextArrow />,
prevArrow: <PrevArrow />,
responsive: [
{
breakpoint: 376,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
},
],
};
작품 DB의 생성일 기준으로 정렬해 신작 콘텐츠 캐러셀로 보여주기
createdDate로 정렬을 해야 하나 싶었는데 별도로 정렬할 필요 없이 DB에서 정렬이 된다.
이 날 한번에 모든 정보를 입력해서 바로바로 갱신이 되었다.
작품 DB에서 스릴러/미스터리 장르의 작품만 필터해 장르 콘텐츠 캐러셀로 보여주기
장르는 별도로 필터링을 거쳐야 한다.
위와 똑같이 자르되 작품 DB에서 장르를 등록할 때 스릴러였던 것만 잘라 thrilerMovies로 만들어 setSlide3에 넣는다.
useEffect(() => {
axios
.get(
$`{serverURL/movies/all}`
)
.then((response) => {
const slideData = response.data.map((item) => ({
id: item.id,
imageSrc: item.accessKey,
title: item.title,
genre: item.genre,
createdDate: item.createdDate,
explanation:
item.explanation.length > 238
? item.explanation.slice(0, 238) + '...'
: item.explanation,
}));
// "스릴러" 장르인 영화만 필터링하여 slide3에 설정
const thrilerMovies = slideData.filter(
(movie) => movie.genre === '스릴러'
);
setSlide3(thrilerMovies);
})
.catch((error) => {
console.error('Error:', error);
});
대략 이런 식으로 나온다.
메인 화면 1개만 했는데 벌써 진이 빠진다...
다음은 똑같이 캐러셀을 사용한 카테고리랑 검색 화면에 대해 써보겠다. 😊
'🛠️ 프로젝트 > ☁️ 구름톤 Hongflix' 카테고리의 다른 글
[Hongflix] 모달 & 마이 페이지 & 카카오 결제하기 API (5) | 2023.10.28 |
---|---|
[Hongfilx] 장르별 콘텐츠 보여주기 & debounce로 실시간 검색 결과 보여주기 (1) | 2023.10.28 |
[HongsamSNS] 로그인/회원가입 구현하기 (1) | 2023.10.18 |