Skip to content

AI를 활용한 맞춤형 플레이리스트 생성&추천 및 음악 재생 플랫폼

Notifications You must be signed in to change notification settings

junny97/Mudig

 
 

Repository files navigation

Mudig

🎵 Mudig 이용하러 가기

🔗 Mudig 개발일지 Notion

Mudig 목업

테스트 계정

로그인 유형 아이디 비밀번호
테스트 계정 mudig011@email.com Mudig011

프로젝트 소개

뮤딕(Mudig, Music Digging 이하 뮤딕)은 사용자들이 새로운 음악을 발견하고, 추천받으며, 공유할 수 있는 플랫폼입니다.

GPT (Generative Pretrained Transformer) 기술과 Karlo(T2I, Text to Image)를 사용하여 개인화된 음악 추천과 인터렉티브한 경험을 제공하는 서비스입니다.

저희 뮤딕은

  1. 자체 회원가입 뿐만 아니라 구글, 카카오를 이용한 소셜 로그인을 지원하고 있습니다.
  2. 인공지능을 통해 새로운 음악을 찾는 즐거움을 드릴 수 있습니다.
  3. 자신만의 플레이리스트를 공유하고, 소통하며 무료한 일상에 소소함 즐거움을 느낄 수 있도록 장소를 제공해드립니다.

디깅이란?

‘디깅’이란 원래 디제이가 자신의 공연 리스트를 채우기 위해서 음악을 찾는 행위를 의미하나, 현재는 자신의 특색있는 플레이리스트를 짜는 것으로 그 의미가 확대 되어 일반인들도 사용하는 언어가 되었습니다.

🛠️ 테크 스택

사용 기술              
패키지  
포맷터  
배포
협업        
IDE  

👨‍👩‍👧‍👧 팀원 소개


김예지


윤서준


이지수


차다연

FE-Roles

협업 방식

일정

schedule

Git 브랜치 전략

  • main : 최종 배포를 위한 브랜치
  • develop : feature에서 개발한 코드를 합쳐 확인하는 브랜치
  • feature : 페이지 단위로 기능을 개발할 때 사용하는 브랜치

커밋 컨벤션

- Feat : 새로운 기능, 특징 추가
- Fix : 수정, 버그 수정
- Docs : 문서에 관련된 내용, 문서 수정
- Comment: 필요한 주석 추가 및 변경
- Style : 스타일링, 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우
- Refactor : 리팩토링
- Test : 테스트 코드 수정, 누락된 테스트를 추가할 때, 리팩토링 테스트 추가
- Chore: 빌드 업무 수정, 패키지 매니저 수정
- Remove: 파일을 삭제하는 작업만 수행한 경우
- Rename: 파일 혹은 폴더명을 수정하거나 옮기는 작업만인 경우

GitHub

GitHub Project

image

GitHub issue template

이슈 템플릿 버그 이슈 템플릿 image

GitHub PR template

PR 템플릿 image

아키텍처

Architecter

페이지 소개

  • 회원가입 페이지

    이메일 회원가입 구글 회원가입 카카오 회원가입
    이메일회원가입 구글 회원가입 카카오 회원가입
  • 로그인 페이지

    이메일 로그인 구글 로그인 카카오 로그인
    이메일로그인 구글 로그인 카카오 로그인
  • 홈 페이지

    스플래시
    홈 스플래시
  • 검색 페이지

    검색
    검색
  • 플리 생성 페이지

    플리 생성
    플리생성
  • 플리 상세 페이지

    플리 상세 플리 삭제 플리 좋아요
    플리상세 플리상세 플리좋아요
댓글 대댓글
댓글 대댓글
  • 플리 수정 페이지

    플리 수정
    플리수정
  • 랜덤 뮤비 페이지

    랜덤 뮤비 랜덤뮤비_곡추가
    랜덤뮤비 랜덤뮤비곡추가
  • 프로필 페이지

    마이 프로필 아더 프로필 로그아웃
    마이프로필 아더프로필 로그아웃
  • 팔로우

    팔로우&언팔로우 팔로잉&팔로워 리스트
    팔로우&언팔로우 팔로우&언팔로우
  • 프로필 수정 페이지

    프로필 수정
    프로필수정
  • 이벤트 페이지

    이벤트 페이지
    랜덤뮤비
  • 비밀번호 변경, 회원탈퇴 페이지

    비밀번호 변경 회원탈퇴
    비밀번호변경 회원탈퇴

핵심 기능/코드

️1️⃣ React-Player를 활용한 비디오 플레이어 커스텀

적용 이유

  • 사용자가 재생을 직접 제어하고 디자인에 따라 커스텀 된 비디오 플레이어 화면을 보여주기 위해 React-Player 라이브러리를 도입하였습니다.

사용 방식

  • 재생, 일시정지, 슬라이더(ProgressBar)를 통해 사용자가 비디오 플레이어를 직접 제어할 수 있도록 구현했습니다.
  • 플레이리스트 목록에서 해당 음악 선택 혹은 현재 음악 종료 시 자동으로 다음 곡 재생 기능을 구현했습니다.
  • range type의 input 태그를 활용하여 비디오의 재생 상태를 보여주고 재생 위치를 조절할 수 있는 슬라이더(ProgressBar)를 구현했습니다.
코드보기
export default function MusicPlayer(props) {
  const { pause, setPause, musicList, currMusic, setCurrMusic } = props;
  const playerRef = useRef(null);
  const [ready, setReady] = useState(false);
  const [played, setPlayed] = useState(0);
  const [duration, setDuration] = useState(0);

  const onEnded = () => {
    if (currMusic === musicList.length - 1) setReady(false);
    else setCurrMusic((prev) => prev + 1);
  };

  //  현재 재생 시간과 전체 비디오 시간 포맷 설정
  function formatTime(seconds) {
    const minutes = Math.floor(seconds / 60);
    seconds = Math.floor(seconds % 60);
    return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
  }

  return (
    <MusicPlayerWrap>
      <ReactPlayer
        url={musicList[currMusic]}
        ref={playerRef}
        className='player'
        playing={!pause} // 재생 상태, true = 재생중 / false = 일시 중지
        controls={false} // 유튜브 재생바 노출 여부
        width='100%'
        height='100%'
        onPlay={() => setPause(false)}
        onPause={() => setPause(true)}
        onEnded={onEnded} // 현재 영상 종료 시
        onReady={() => setReady(true)} // 영상이 로드되어 준비된 상태
        onDuration={setDuration} // 총 재생 시간
        onProgress={({ played }) => !pause && setPlayed(played)} // 현재 재생 시간
      />
      {ready && (
        <ProgressBar>
          <time dateTime='P1S'>{formatTime(played * duration)}</time>
          <input
            type='range'
            min='0'
            max='0.999999'
            step='any'
            value={played}
            style={{ '--progress': `${played * 100}%` }}
            onChange={(e) => {
              setPlayed(parseFloat(e.target.value));
              playerRef.current.seekTo(parseFloat(e.target.value));
            }}
          />
          <time dateTime='P1S'>{formatTime(duration)}</time>
        </ProgressBar>
      )}
    </MusicPlayerWrap>
  );
}

2️⃣ React-Hook-Form 적용

적용 이유

  • React-Hook-Form은 입력 필드 갱신 및  리렌더링을 최소화하여 불필요한 작업을 방지하고 빠른 사용자 경험을 제공하기에 도입하였습니다.
  • React-Hook-Form은 사용하기 쉽고 직관적인 API를 제공합니다. 필요한 기능을 간단한 훅 함수로 호출하고, 컴포넌트 내에서 필요한 상태와 메서드를 사용할 수 있어 보다 효율적으로 폼 로직을 작성할 수 있습니다. -React-Hook-Form은 유연하고 확장 가능한 구조를 가지고 있습니다. 내장된 메서드들 뿐만 아니라 직접 커스텀 유효성 검사 규칙을 생성하여 폼의 요구사항을 관리할 수 있습니다.

사용 방식

  • 기존 Input 컴포넌트에 React-Hook-Form 속성을 적용시킨 뒤 React-Hook-Form에서 제공하는 formProvider를 사용하여 form 하위 input들의 값들을 사용할 수 있는 제어 컴포넌트를 생성하여 사용하였습니다.

3️⃣ 무한 스크롤

적용 이유

  • 랜덤 뮤비 감상 시 무한 스크롤을 적용하여 스크롤 이벤트 발생 시 자연스럽게 새로운 뮤비들을 로드하여 사용자가 이탈하지 않고 계속해서 콘탠츠를 둘러볼 수 있도록 하기 위해 적용하였습니다.

사용 방식

  • Intersection Observer API 라이브러리를 이용하여 무한스크롤을 구현하였습니다.
  • useRef()를 사용하여 targetRef 객체를 만들고 IntersectionObserver를 활용하여 사용자의 화면이 끝나갈떄를 감지하여 새로운 뮤비 정보 데이터를 추가로 호출합니다.
  • useEffect 훅을 사용하여 컴포넌트가 렌더링될 때마다 무한 스크롤 이벤트를 감지합니다.
  • 더이상 보여줄 데이터가 없다면 토스트 메시지를 통해 데이터 호출이 끝났음을 알려줍니다.
코드 보기

랜덤뮤비 코드중 일부

import { useRandomMv } from '../../hooks/queries/useRandomMv';
export default function RandomMusic() {
  const { mutate: getRandomMv } = useRandomMv();
  const [id, setId] = useState([]);
  const selectId = id.join(',');
  const targetRef = useRef(null);

  useEffect(() => {
    const fetchRandomMv = async () => {
      const data = { selectId, page };

      getRandomMv(data, {
        onSuccess: (newVideoData) => {
          if (newVideoData.length === 0) {
            setIsEnd(true);
            return;
          }
          //받아온 뮤비들의 id값 갱신하여 [id, setId] 에 저장
          const dataId = newVideoData.map((video) => video.id);
          setId((prevId) => [...prevId, ...dataId]);
          setPage((prevPage) => prevPage + 1);
          //기존 비디오랑 새로 받아오는 비디오
          setAllVideos((prevVideos) => [...prevVideos, ...newVideoData]);
        },
        onError: (error) => {
          console.error('랜덤뮤비 불러오기 실패', error);
        },
      });
    };

    const observerCallback = async ([entry]) => {
      if (entry.isIntersecting) {
        await fetchRandomMv();
      }
    };

    const observerOptions = {
      threshold: 1,
    };

    const observer = new IntersectionObserver(
      observerCallback,
      observerOptions,
    );

    if (targetRef.current) {
      observer.observe(targetRef.current);
    } else if (!isEnd) {
      // 더 이상 데이터가 없는 경우
      InfoToast.fire({
        icon: 'info',
        title: '더 이상 보여줄 뮤비 정보가 없습니다!',
      });
    }

    return () => {
      observer.disconnect();
    };
  }, [page]);
  return (
    <>
      {toast && (
        <Toast setToast={setToast} text={toast.content} type={toast.type} />
      )}
      <MainHeader />

      <PlayerWrap>
        {allVideos &&
          allVideos.map((video, index) => (
            <PlayerBox id={video.id} key={index}>
              <VideoPlayer url={video.information} />
              <VideoInfo
                title={video.song}
                views={video.singer}
                onAddButtonClick={() => handleAddButtonClick(video.id)}
              />
            </PlayerBox>
          ))}

        <div ref={targetRef} />
        {modalOpen && <AddModal videoId={videoId} />}
        <MoveToTopButton></MoveToTopButton>
      </PlayerWrap>
    </>
  );
}

4️⃣ 플리 수정 (순서 수정)

적용 이유

  • 플레이리스트를 수정할 때 요구사항 중 하나인 순서 변경에서 drag & drop 기능을 사용해야 했습니다.
  • 이때, 단순히 순서만 변경하면 되었기에 초보자도 쉽게 쓸 수 있는 라이브러리이면서, 성능과 애니메이션 효과가 돋보이는 react-beautiful-dnd 라이브러리를 사용하여 구현하기로 결정했습니다.
코드 보기
export default function PlayListModify({ playlistDesc }) {
  const navigate = useNavigate();
  const [playlistInfo, setPlayListInfo] = useRecoilState(PlayListAtom);
  const [music, setMusic] = useState(playlistInfo.music || []);
  const { mutate: modifyPlaylist } = useModifyPlaylist(
    playlistInfo.playlist.id,
  );
  const [delMusic, setDelMusic] = useState([]);
  const [changedOrder, setChangedOrder] = useState([]);
  const [toast, setToast] = useRecoilState(toastAtom);

  const handleDelBtn = (itemId) => {
    const newMusic = music.filter((item) => item.id !== itemId);
    setMusic(newMusic);
    const newOrder = changedOrder.filter((item) => item !== itemId);
    setChangedOrder(newOrder);
    setDelMusic([...delMusic, itemId]);
  };

  const handleModifyClick = (e) => {
    const reqData = {
      del_music_list: delMusic.join(','),
      move_music: changedOrder.join(','),
      title: playlistDesc.title,
      content: playlistDesc.content,
      is_public: playlistDesc.is_public,
    };
    modifyPlaylist(reqData, {
      onSuccess: () => {
        setToast({ content: '수정에 성공하였습니다.', type: 'success' });
        navigate(-1);
      },
      onError: (error) => {
        setToast({ content: '수정에 실패하였습니다.', type: 'warning' });
      },
    });
  };

  const onDragEnd = ({ source, destination }) => {
    if (!destination) return;

    const _items = JSON.parse(JSON.stringify(music));
    const [targetItem] = _items.splice(source.index, 1);
    _items.splice(destination.index, 0, targetItem);
    setMusic(_items);

    const newOrder = _items.map((item) => item.id);
    setChangedOrder(newOrder);
  };

  return (
    <PlayListModifyWrap>
      <DragDropContext onDragEnd={onDragEnd}>
        <Droppable droppableId='droppable'>
          {(provided) => (
            <PlayList
              innerRef={provided.innerRef}
              droppableProps={provided.droppableProps}
            >
              {music.map((item, index) => (
                <Draggable
                  draggableId={`${item.id}`}
                  index={index}
                  key={item.id}
                  disableInteractiveElementBlocking
                >
                  {(provided) => (
                    <PlayListItem
                      innerRef={provided.innerRef}
                      dragHandleProps={provided.dragHandleProps}
                      draggableProps={provided.draggableProps}
                      modify={true}
                      img={item.thumbnail}
                      title={item.song}
                      info={item.singer}
                    >
                      <DelBtn
                        type='button'
                        name='삭제'
                        onClick={() => handleDelBtn(item.id)}
                      >
                        <img src={CloseIcon} alt='삭제' />
                      </DelBtn>
                    </PlayListItem>
                  )}
                </Draggable>
              ))}
              {provided.placeholder}
            </PlayList>
          )}
        </Droppable>
      </DragDropContext>
      <SaveBtn onClick={handleModifyClick}>저장</SaveBtn>
    </PlayListModifyWrap>
  );
}

트러블 슈팅

크로스 브라우징 이슈 - 모바일 브라우저 화면 잘림 현상

모바일 브라우저(주로 ios, safari)에서 기본 주소 표시줄과 하단바 영역으로 인해 아래 혹은 윗부분의 요소가 잘려보이는 현상

  • 처음 페이지 이동 시 초기 화면이 위쪽 요소부터 보이지 않고 중간 위치부터 보임
  • 높이가 화면에 맞춰지지 않고 화면 높이보다 길어짐. 아래에 위치하는 요소가 더 밑에 위치하게 되면서 잘려 보임

원인

ios safari 브라우저의 경우, 상단의 url바 혹은 하단의 툴바로 인해 화면의 크기(viewport)를 실제 보여지는 윈도우 innerHeight보다 크게 잡음. 그래서 height을 100vh로 잡아 작성하더라도 실제 safari 모바일 화면에서는 스크롤이 생길만큼 공간이 생기면서 발생하는 현상

해결 방법

dvh 단위 사용 주소 표시줄이 스크롤을 통해 축소가 되는지 노출이 되고 있는지에 상관 없이 현재 보여지는 뷰포트 높이를 동적으로 가져옴

크로스 브라우징 이슈 - 모바일 Input 화면 확대 현상

모바일 인풋창 클릭 시 확대되는 현상 (ios에서 발생)
- index.html 파일 내 viewport meta 태그 props에 content= `'user-scalable=no'` 설정 추가
<meta
  name="viewport"
  content="width=device-width, initial-scale=1, user-scalable=no"
/>

리액트 쿼리 - Delete 요청 후 기존 캐싱된 쿼리 데이터 제거

useMutation으로 데이터를 삭제하는 Delete 요청 후 다른 데이터를 불러올 때 발생한 오류 ex) 플레이리스트 상세 페이지의 데이터를 삭제한 후 다른 플레이리스트 상세 페이지로 이동 시 에러 발생

원인

삭제한 데이터가 리액트 쿼리 데이터에 남아있어 발생하는 오류

해결 방법

useMutation Delete 요청 성공 시 쿼리 데이터를 제거하는 queryClient.removeQueries() 적용

리액트 쿼리 - fetching된 데이터 실시간 갱신

GET - 'refetch()' 메서드를 통한 갱신

해당 쿼리에 해당하는 데이터를 서버로부터 다시 가져올 때 사용

  • 1번 검색 후 재검색 요청 시 재검색 된 keyword에 해당하는 데이터로 갱신되지 않는 오류 발생

    export const useSearch = (query) => {
      const { data, isLoading, refetch } = useQuery(
        'get-search',
        () => {
          return privateInstance.get(`/playlist/search/?query=${query}`);
        },
        { select: (response) => response.data },
      );
      return { data, isLoading, refetch };
    };
    
    export default function SearchResult() {
      const { keyword } = useParams();
      const { data, isLoading, refetch } = useSearch(keyword);
      useEffect(() => {
        refetch();
      }, [keyword]);
    
      //...
    }

    useQuery 훅에서 refetch 함수를 반환한 후, 컴포넌트 내 useEffect 훅 내부에서 refetch 함수를 호출하여 keyword가 변경될 때마다 'get-search' 쿼리가 다시 실행되어 최신의 검색 결과를 가져오게 함.

    POST/PUT/DELETE - 쿼리를 무효화하는 명령어'queryClient.invalidateQueries()'를 사용하여 갱신

    프로젝트 진행 중, API를 통해 데이터를 POST하거나 DELETE할 때, 요청은 성공적으로 처리되었으나, 현재 화면에 변화된 데이터가 반영되지 않는 문제에 직면했습니다. 이는 페이지 컴포넌트 내의 각 요소 컴포넌트가 변경사항을 감지하고 업데이트하지 못하는 문제로 밝혀졌습니다.
    이 문제를 해결하기 위해,`queryClient`의 `invalidateQueries` 기능을 활용해, GET 요청 때 생성한 쿼리 키에 해당하는 캐시된 데이터를 무효화하여, 쿼리가 실행될 때 캐시를 사용하지 않고 서버로부터 새로운 데이터를 가져와 데이터를 즉시 갱신하는 방법으로 해결하였습니다.
    • 구현 과정: 버튼 클릭 시 create mutation을 실행하고, onSuccess 콜백 함수에서 invalidateQueries()를 호출하도록 설정했습니다.
    • 결과: 이 방법을 적용한 결과, invalidateQueries()가 자동으로 최신 값으로 데이터를 다시 가져오는(refetch) 작업을 수행하게 되었고, 이는 화면에 실시간으로 데이터가 업데이트되는 효과를 가져왔습니다.
      const useFollowUser = () => {
        const queryClient = useQueryClient();
    
        const postFollow = useMutation(
          (userId) => privateInstance.post(`/user/${userId}/follow/`),
          {
            onSuccess: () => {
              // 팔로잉 목록 쿼리 갱신
              queryClient.invalidateQueries('get-following');
              queryClient.invalidateQueries('get-follower');
              queryClient.invalidateQueries('get-profile');
            },
          },
        );

느낀점

김예지

이번 프로젝트를 통해 백엔드 개발자와 디자이너와 소통하면서 전체적인 흐름을 파악할 수 있고 각 파트간의 어떤 의사소통이 이뤄져야 하는지 배울 수 있었던 기회여서 좋았습니다. PM이 없어서 프론트엔드의 역할과 기획의 역할을 같이 하면서 프론트엔드의 업무에 대해 이해도를 높일 수 있었습니다. 다른 파트의 작업이 진행되어야 개발을 시작할 수 있고 다른 파트의 수정사항이 스노우볼처럼 굴러오는 것을 보면서 프로젝트 기한 내에 업무를 마무리하기 위해서는 커뮤니케이션 능력이 정말 중요하고 필수적이라는 것을 경험하게 되었습니다. 프론트엔드 리더로 프로젝트를 진행하면서 부족한 부분도 많았을텐데 함께 해준 팀원분들에게 감사하고 모두 고생 많으셨습니다.

차다연

프론트엔드, 백엔드, 디자이너가 기획부터 배포까지 전 과정을 함께 수행하며 PM이 없는 상태에서 협업한 과정이 쉽지만은 않았지만 그럼에도 서로의 부족한 점을 채워주고 열심히 소통하고 이해하려 노력한 과정 속에서 분명한 보람과 즐거움을 느낀 경험이었습니다 :)
서로 다른 분야와 함께하며 프론트엔드의 역할을 보다 명확히 이해할 수 있었고, 백엔드, 디자이너와의 조율 과정 속에서 초기 기획 설정과 커뮤니케이션이 얼만큼 중요한지도 실감할 수 있었던 것 같습니다. 특히 백엔드 개발자와의 협업을 통해 API 연동의 중요성을 크게 느낄 수 있었고, 디자이너와의 소통을 통해 사용자 경험(UX) 개선의 필요성을 더욱 인식하게 된 것 같아요.
함께한 팀원 모두 수고 많으셨습니다~!

이지수

프론트 개발자 외에도 백엔드 개발자와 디자이너와 협업할 수 있었던 좋은 경험이었습니다. 기획 단계를 거치면서 세세한 부분까지 정해야 되고, 개발하면서도 많은 변경을 경험하게 되면서 소통의 중요성도 깨닫게 되었습니다. 처음부터 끝까지 팀원들과 함께 만들어냈다는 사실이 너무나도 보람된 일이었습니다. 또한, 유저 피드백도 받게 되면서 실제 서비스처럼 피드백 반영도 하고 여러 사람들의 의견을 들을 수 있어 즐거웠습니다. 프로젝트 기간 동안 뮤딕 팀원분들 모두 고생하셨습니다.

윤서준

이번 뮤딕 프로젝트를 통해 디자이너와 백엔드 개발자분들과 협업을 진행하게 되었습니다. PM이 없는 상태에서 기획을 시작하여 중간 기획 수정사항이 여러번 생기기도 했지만, 팀원들간의 지속적인 소통을 통해 원할하게 조정할 수 있었습니다. 또한 백엔드 분들과의 협업을 통해 API의 설계부터 생성까지의 작업을 확인하고 이해할 수 있게 되어 좋았습니다.

유저 피드백 이후

🔼 Top

About

AI를 활용한 맞춤형 플레이리스트 생성&추천 및 음악 재생 플랫폼

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 99.5%
  • HTML 0.5%