[React] - 음악 앱 UI 구현하기

ain·2022년 7월 28일
6

미니프로젝트

목록 보기
2/2
post-thumbnail

음악 앱 SPA로 구현하기

본 프로젝트는 음악 서비스 앱 벅스를 참고하여 만들었습니다.

이번에 react-router-dom을 배우면서 Single Page Application의 장점을 조금 더 음미하게 됐고 동작방식을 더 깊게 알아가게 된 계기가 되었다. 이걸 배우고 구현을 해보니까 이제 페이지 로딩을 기다려야 하는 사이트에 들어가면 가슴이 너무 답답해진다...😩

기술


  • react-router-dom
    • BrowswerRouter, Routes, Route
    • Link
    • Outlet
    • useNavigate
    • useParams
    • useLocation
  • useSWR
  • useReducer
  • axios 라이브러리
  • styled-components 💅

구조


우선, react-router를 중심으로 만들었기 때문에 router에서 쓸 수 있는 기술들을 적극 활용하여 전체구조는 BrowserRouter, Router, Route로 감싸 주었다.

<BrowserRouter>
  <Routes>
    <Route path={'/'} element={<Home />} />
      ...
  </Routes>
</BrowserRouter>

👍🏻[추천리스트]

추천 곡 리스트가 담긴 'ㅇㅇ님을 위한 추천'은 useParams로 Route path에 새미콜론을 붙힌 id를 파라미터로 부여하여 각각 상세페이지를 만들 수 있도록 하였다.

// App.js
<Route path={'/:id'} element={<Recommand />} />
// Home.js
<Link to={`${data.id}`} state={{ recommandData }}>

useParams를 사용 할 때에는 Route로 먼저 경로를 설정해서 컴포넌트를 감싸준 다음, Link를 이용하여 실제 페이지로 이동하는 링크를 눌렀을 때 해당 Route에 감싸져있던 컴포넌트로 이동하게 해준다.
이 Link의 속성 to에 경로로 설정해줄 값을 넣을 수 있다. 만약 data.id로 설정하면 객체에 id값으로 있는 0, 1, 2, 3...이런식으로 경로가 설정될 것이고, data.title이라고 해준다면 data의 title값으로 있는 추천 플레이리스트의 제목이 경로로 설정 된다.

useParams로 상세페이지 만들기

Link태그를 보면 to 속성 뒤에 state라는 속성도 있는데 이는 상세페이지를 각각 만들기 위해 사용되는 것이며 useLocation과 연결되어 있다.
그저 useParams만 사용하면 data의 id를 전달하는 것밖에 못하는 것 같아 찾아보았는데 많은 방법이 있었지만 그 중 useLocation이 눈에 들어와 이것으로 정했다.
상세페이지를 만들기 위한 컴포넌트 Recommand.js 파일로 들어가서useParams와 useLocation를 가져와준다.

// Recommand.js
import { useParams, useLocation } from 'react-router-dom';
const location = useLocation();
const params = useParams();
console.log(params);
// {id: '0'}
console.log(location);
/*
{pathname: '/0', search: '', hash: '',
state: {…}, key: '7pkxelt7'}
*/

params와 location을 각각 출력해보면 각자 다른 객체가 나온다.
params는 우리가 경로로 설정해준 id 하나만 객체로 들어가서 나오고, location은 조금 다르게 나온다. 여기서 state객체를 자세히 보면 내가 받아온 데이터가 배열형태로 저장되어있는 것을 볼 수 있다. 여기서 데이터를 가져올 수 있는 이유가 바로 Home.js에서 recommandData 데이터를 Link의 state 속성으로 전달해줬기 때문에 location으로 가져올 수 있는 것이다.

이렇게 받아온 객체로 데이터를 접근해서 상세페이지를 만들어 줄 수 있다. Recommand.js파일에서의 경우 이렇게 접근했다.

// 추천 플레이리스트의 주제 부분 
<div>{location.state.recommandData[params.id].title}</div>
// 컴포넌트에 props 전달 ↓
<Lists playlist={location.state.recommandData[params.id].lists} />

params.id로 받아온 객체의 id를 데이터의 인덱스로 활용하였다. location 객체를 보면 recommandData 데이터가 배열로 나타나 있는데, recommandData[params.id] 이런식으로 데이터 하나하나에 접근한다. 그리고 주제부분을 tilte값으로 렌더링 해야 하기 때문에 recommandData[params.id].title 로 값을 가져와준다.

Lists라는 컴포넌트는 곡 리스트를 재사용가능하게 만들어 놓은 컴포넌트이다. 구조와 스타일링은 비슷하되 데이터가 각기 다르기 때문에 props로 배열형태의 데이터를 전달하기만 하면 재사용할 수 있게 만들어 놓았다.

{location.state.recommandData[params.id].lists}
이렇게 접근하여 데이터에 있는 플레이리스트들을 배열 형태로 전달 해주면 된다.

각각 추천 플레이리스트를 들어가보면 상단에는 플레이리스트 주제가 렌더링 되어 있고, 하단에는 주제에 맞는 리스트들이 렌더링 되어있다.


🔥[지금 차트]


이 부분도 Route처리를 해주었다. 차트 > 버튼에 Link가 걸려있고, 이를 Route로 감싸주었다.

// Home.js (컴포넌트 popNow ↓)
<PopNow />
// popNow.js
<Link to='chartnow'>차트 &gt;</Link>
// App.js
<Route path={'chartnow'} element={<ChartNow />} />

지금 차트 안으로 들어가 보면 현재 차트에 있는 리스트들이 '곡'과 '앨범'으로 나뉘어져 있다. 곡을 클릭하면 곡 차트가 나오고 앨범을 클릭하면 앨범 차트로 바뀐다.

이 부분은 Link를 걸어서 따로 앨범 컴포넌트를 만들어 Route를 중첩할까..아님 useState를 쓸까 고민하다가 리액트 강의를 조금 더 들어보면 답이 나올 것 같아 일단 스킵했던 부분이었다.
이때 useReducer를 배우게 되었는데 여기에 딱 적합할 것 같아 useReducer를 사용하였다.

useReducer

useReducer는 useState와 같은 목적으로 상태값을 바꿀 때 쓰이지만 조금 더 복잡해질 때 사용 할 수 있다.
사용 방법도 사용하는 순서와 과정을 알고 나면 엄청 간단하다.

  1. useReducer를 import 하여 가져와준다.
  2. useState는 상태값 state와 함수 setState를 가져오는 것처럼 useReducer에서는 상태값 state와, 상태값의 이름표같은 역할을 하는 dispatch, 실제 state를 바꾸는 reducer 함수, 그리고 state의 초기값 iniArg를 가져와준다.
  3. JSX에서 상태값을 바꿔줄 이벤트를 걸어주고 그 안에 dispatch를 넣어준다. dispatch에는 action이라는 인자를 받고 키 밸류 형태로 적어준다. (ex: { type: 'SONG' }) 여기서 받아온 인자로 reducer 함수를 실행시킨다.
  4. 상태의 초기값인 initArg를 객체로 만들어준다.
  5. reducer 함수를 선언해준다. 이때 함수는 state와 action 두가지 인자를 받는다. state는 상태값이고, action은 dispatch에서 인자로 전달 되었던 { type: 'SONG' } 값이 action이다.

샘플 코드

// useReducer 가져오기
import { useReducer } from 'react';
// reducer 함수
const reducer = (state, action) => {
  switch(action.type) {
    case 'SONG' :
      return {
        songStyle: { color: 'black', borderBottom: '1px solid black' },
        albumStyle: {
          color: 'rgba(0,0,0,.4)',
        },
        render: <ChartList />,
      }
     break;
    case 'ALBUM' :
      return {
        songStyle: { color: 'rgba(0,0,0,.4)' },
        albumStyle: { color: 'black', borderBottom: '1px solid black' },
        render: <AlbumChartList />,
      }
      break;
    default:
      break;
  }
}
// initArg
const initArg = {
  render: <ChartList />,
  songStyle: { color: 'black', borderBottom: '1px solid black' },
  albumStyle: { color: 'rgba(0,0,0,0.4)' },
}
// useRedeucer 선언
const [state, dispatch] = useReducer(reducer, initArg);
// 메인 함수
const Component = () => {
  // JSX (dispatch)
  return (
  <button onClick={() => { 
    dispatch({ type: 'SONG' })
    }}></button>
  <button onClick={() => { 
    dispatch({ type: 'ALBUM' });
  }}>앨범</button>
 );
};

이렇게 useReducer를 사용하여 '곡' 버튼을 클릭했을 땐 reducer에 case 'SONG'이 반환되게, 또 '앨범' 버튼을 클릭했을 땐 reducer에 case 'ALBUM'이 반환 될 수 있도록 구현하였다.

이 부분에서 삽질을 많이 해서 그런지 너무 뿌듯한 부분이다...🥺


🎵[재생목록]

이 페이지에서도 useReducer를 사용하였다.
집중적으로 구현한 것은 검색창에 곡명을 검색하면 아래에는 그 알파벳에 해당하는 곡들을 전부 띄우는 것이다. 그리고 취소를 누르면 검색창에 입력한 문자들이 지워지고, 리스트에는 다시 재생목록에 들어있던 곡들이 띄워지게 된다.

함수

재생목록에 있는 곡들 중 곡명만 가져와 songTitle 배열에 넣어준다.

let songTitle = [];
for (let i = 0; i < playingNowData.length; i++) {
  songTitle.push(playingNowData[i].song);
}

검색창에 무언가를 입력하여 onChange이벤트가 실행 되면, 곡명만 들어있는 배열의 요소에 각각 접근한다. 검색창에 입력한 값(e.target.value)이 접근한 요소에 포함(includes)되어 있다면 그 곡 객체를 filteredList배열에 넣어준다.

let filterdList = [];
const searchSong = (e) => {
  songTitle.forEach((keyword) => {
    if (keyword.includes(e.target.value)) {
      let idx = songTitle.indexOf(keyword);
      filterdList.push(playingNowData[idx]);
    }
  });
};

useReducer

상태값은 검색창에 무언가를 입력했을 때(onChange)와 취소 버튼을 눌렀을 때(onClick)의 상태값이 각각 존재한다.
각각 dispatch 인자로 'SEARCH'와 'INIT'으로 주었다.

JSX

<input
  type='text'
  placeholder='곡명 또는 아티스트명을 입력하세요.'
  onChange={(e) => {
     // 입력값이 바뀔 때마다 배열을 비워줘야 키워드에 해당하는 리스트만 나오기 때문에 초기화 시켜줌.
    filterdList = [];
    //입력값과 곡명이 매칭되는지 확인해서 filterdList에 넣어주는 함수
    searchSong(e);
    dispatch({ type: 'SEARCH', payload: filterdList });
  }}
/>
<input
  type='button'
  value='취소'
  onClick={(e) => {
    // e.target.value값을 비워주는 함수.
    clickCancel(e);
    dispatch({ type: 'INIT' });
  }}
/>

reducer
1. 'SEARCH'라면 filterdList(action.payload) 배열의 객체들로 렌더링을 업데이트 해준다.
2. 'INIT'이라면 기존의 데이터를 다시 렌더링 해준다.

const reducer = (state, action) => {
  switch (action.type) {
    case 'SEARCH':
      return {
        ...state,
        render: action.payload,
      };
      break;
    case 'INIT':
      return { ...state, render: playingNowData };
    default:
      return state;
      break;
  }
};

검색하는 화면↓


💡[앨범정보]

곡과 아티스트 등 데이터들은 static 데이터로 직접 만들었었는데 앨범 정보에서는 axios와 useSWR을 한번 다뤄보고 싶어서 JSONplaceholder의 데이터를 가져와보았다.

useSWR

useSWR은 데이터를 가져올 수 있는 훅이다. 첫번째 인자로 데이터의 고유한 식별자인 key문자열(보통API URL)을 받고, 두번째 인자로는 데이터를 반환하는 fetcher를 받는다. 이 fetcher는 비동기가 될 수 도 있기 때문에 axios를 사용해 가져오는 것도 가능하다.

// useSWR을 가져온다.
import useSWR from 'swr'
// component 함수안에서 useSWR은 key문자열과 fetcher함수를 인자로 받는다.
function Component() {
  const { data, error } = useSWR('post', fetcher)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

데이터를 전역에서 사용하려면 데이터를 최상단 컴포넌트에서 뿌려서 자식 컴포넌트가 쓸 수 있도록 해야 한다.
전역에 뿌리기 위해서 App.js에 SWRConfig를 가져와야 한다. fetcher함수도 최상단에서 만들어주고 데이터를 가져와준다.

App.js

// SWRConfig 가져오기.
import { SWRConfig } from 'swr';
// fetcher 함수 만들어 데이터 반환.
function App() {
  async function fetcher() {
    const result = await axios.get(
      'https://jsonplaceholder.typicode.com/posts'
    );
    return result.data;
  }
// SWRConfig로 자식 컴포넌트들을 감싸준다. 나는 앨범정보 컴포넌트에 가져올 것이기 때문에 이 컴포넌트로 예를 들겠다.
  return (
    <SWRConfig value={{ fetcher }}>
      // 데이터가 필요한 자식 컴포넌트
      <Route path={albuminfo} element{<AlbumInfo />} />
    </SWRConfig>
  )
}

AlbumInfo.js

// useSWR 가져오기
import { useSWR } from 'swr';
// useSWR의 인자로 fetcher는 전역에서 value로 뿌려줬기 때문에 안가져와도 되고,
// key부분만 가져오면 됨.
function AlbumInfo() {
  const { data, error } = useSWR('posts');
  // 데이터를 못받아온다면 화면에 🙅🏻‍♀️ 렌더링
  if (error) return <div>🙅🏻‍♀️</div>;
  // 데이터를 아직 못 받아왔다면 'loading...' 텍스트 렌더링
  if (!data) return <div>loading...</div>;
  // 받아온 데이터 사용!
  return (
    <h2>{data[0].title}</h2>
    <div>{data[0].body}</h2>
  )
}

어려웠던 점


  1. 재생목록에서 검색기능 구현을 처음 했을 때 배열을 비워주지 않아서 내가 검색해서 filteredList에 들어갔던 리스트들이 쌓이고 쌓여서 나왔었다.
    요렇게...

    리스트를 구현 할때 map을 사용해서 key값도 줬는데, 고유해야 하는 key값도 충돌이 일어나서 에러가 나기도 했었다.
    이때가 useReducer를 처음 써볼때여서 useReducer에 문제가 있는줄 알고 거기만 계속 파다가...콘솔로 filteredList 배열을 찍어보니 데이터들이 계속 쌓이고 있었다. 그렇게 해서 onChange 이벤트가 발생할때마다 배열을 빈배열로 초기화 시켜주는 걸로 해결하였다.

  2. useParams로만 상세페이지를 구현할 수 있는 건줄 알고 처음에 컴포넌트를 페이지 수대로 만들어서 하나하나 설정해줘야 하는 줄 알았다. 근데 나중에 생각해보니 만약 페이지 수가 1억개라면 하나하나 설정 하진 못할텐데...하고 혼자 생각하는 걸 그만두고 구글링을 해봤더니 답은 많았다. useLocation을 쓰는 방법도 있고, Route의 자식 컴포넌트로 상세페이지 컴포넌트를 하나 만들어서 props로 데이터를 전달하는 방법도 있었다. useLocation이 한눈에 딱 들어와 이것을 쓰기로 결정한 것이다.

아쉬운 점

  1. *재생목록 검색기능은 대소문자가 정확히 일치한 곡명이 있을 때만 화면에 띄워준다. 'Take Me'인데 'take me'로 치면 검색이 안된다.
    *그리고 아티스트 이름으로도 검색 할 수 있게 나중에 업데이트 하고 싶다.

2022.07.31 22:44 업데이트 ddui님의 도움으로 해결↓

입력값부터 toLowerCase()로 소문자를 입력을 받은 다음, 검색하는 이벤트 타겟도 toLowerCase()로 소문자로 일치시키면 소문자 및 대문자 모두 검색이 가능해진다.

songTitle.forEach((keyword) => {
  if (keyword.toLowerCase().includes(e.target.value.toLowerCase())) {
    idx = songTitle.indexOf(keyword);
    filteredList.push(playingNowData[idx]);
  }
});

2022.08.01 00:16 업데이트 nogy님의 도움으로 해결↓

songTitle은 곡 데이터에서 곡명을 따로 받아온 배열이였는데, 굳이 따로 만들 필요없고 곡 데이터에서 바로 접근하여 코드를 깨끗하게 짜는 방법을 배웠다.

let filteredList = [];
const searchSong = (e) => {
  let input = e.target.value.toLowerCase();
  // 곡 데이터(playingNowData)에 직접 접근한다.
  playingNowData.forEach((data) => {
    if (
      // song이나 artist에 입력값(input)이 포함되어 있다면
      data.song.toLowerCase().includes(input) ||
      data.artist.toLowerCase().includes(input)
    ) {
      // 리스트 렌더링
      let idx = playingNowData.indexOf(data);
      filteredList.push(playingNowData[idx]);
    }
  });
};

이렇게 해서 곡명과 곡 아티스트를 대소문자 구분없이 검색할 수 있게 된다.

  1. 앨범 정보도 곡에 따라 각각 앨범정보가 뜨게 하고 싶었는데 데이터를 받아올 곳이 없어 다 static 데이터로 입력해야 해서... 시간관계상 하지 못하였다. 추후에 음악 api를 받아와서 작업 해보고싶다.
  2. 컴포넌트 파일들을 나름 보기좋게 정리한다고 해서 하긴 했는데 남이 보기에는 좀 보기 힘들 것 같다. 컴포넌트 정리 방법도 구글링 해봐야겠다.

참고

profile
프론트엔드 개발 연습장 ✏️ 📗

2개의 댓글

comment-user-thumbnail
2022년 7월 30일

대소문자는 keyword.toLowerCase().includes(e.target.value.toLowerCase())로 하시면 해결될 것 같아요

1개의 답글