React Native로 Spotify 맛보기

Spotify Clone Project: Melodify

갑자기 변한 상황으로 기업협업 대신에 위코드에 남아 개인공부를 하게되었고,
React Native를 배우기 시작하면서 동기의 추천으로 Spotify 클론을 3주간 진행했다..

1. Spotify 클론 프로젝트

  • 프론트 개발자로서 따로 백엔드 데이터에 신경을 쓰지 않기 위해 데이터는 Spotify에서 제공하는 API를 사용해서 진행했다. 개인적으로 기능을 살펴보기 위해 앱을 사용해봤을때 복잡도와 완성도가 높아 개인적인 목표로 주요 필수 기능 선택구현과 Redux로 전역상태 관리를 해보자고 마음먹었다.

  • 프로젝트 개발 기간:
    2021/2/24 ~ 2021/3/12, 17일간

  • 구성원:
    Johnny Tae, 태성현 (front-end) 1 명

2. 사용된 기술

  • Development environment : Expo
  • Framework : React Native
  • DataBase : Spotify Developer Api
  • Library : React Navigation, Redux, Axios, Expo-AV, lodash-debounce, and more

3. 실제 구현한 기능

  • 기본적인 레이아웃 및 디자인
  • Spotify Api를 사용해 카테고리, 플레이리스트, 음악 데이터 불러오기
  • Axios instance 를 활용한 중복적인 코드 제거
  • Expo-av(expo audio) 라이브러리를 사용한 음악 재생
  • Redux 전역 상태관리를 활용해 어디서든 음악 상태 변경 가능 및 좋아요 기능
  • CustomBottomTab Navigator (음악 플레이어 부분)
  • 바텀탭 베이스의 스택 네스팅 Navigation
  • FlatList를 활용한 여러방식의 카테고리 및 트랙 리스팅

4. Code Review

- 이미지 클릭시 영상으로 이동 -

[1] React Navigation Nesting

  • 프로젝트 시작과 함께 바로 마주쳤던 문제였다. 기존의 react 페이지 개념과 react router과는 다른 Stack, BottomTab, MaterialTopTab 등 여러가지의 navigator를 연동되게 사용해야할 때 nesting을 해주어야 한다는 것을 react navigation 공식 문서를 살펴보다가 알게되었다. 사실상 거의 기본 스켈레톤 세팅 부분부터 막히던 느낌이라 조급한 마음에 잘 이해가 가지 않았지만, 하나를 제대로 작성하고 보니 생각보다 쉬운 개념이였다.
<NavigatorName.Navigator>
	<NavigatorName.Screen name="지정명" component={불러올컴포넌트명(스크린)} />
	<NavigatorName.Screen name="지정명" component={불러올컴포넌트명(스크린)} />
</NavigatorName.Navigator>

위와 같은 형식으로 만들고 최상단(Base)의 경우 <NavigationContainer> 로 아래와 같이 감싸주어야 한다. 나의 경우 BottomTab Navigator가 최상단이기 때문에 아래와 같이 작성하고, 불러오는 컴포넌트가 또하나의 위와같은 형식으로 작성된 Stack Navigator이고 그것을 불러오는 식으로 Nesting을 해주었다.

const Tab = createBottomTabNavigator();

export default function Tabs() {
  return (
      <NavigationContainer>
        <Tab.Navigator
          initialRouteName="Home"
          tabBar={props => <MyTabBar {...props} />}
        >
          <Tab.Screen
            name="Home"
            component={HomeStack}
            listeners={({ navigation }) => ({
              tabPress: e => {
                navigation.navigate(HomeRoot);
              },
            })}
          />
          <Tab.Screen name="Search" component={SearchStack} />
          <Tab.Screen name="Library" component={LibraryTopTab} />
          <Tab.Screen name="Premium" component={PremiumRoot} />
        </Tab.Navigator>
      </NavigationContainer>
  );
};
  • Spotify 앱을 보면 기본적으로 Home, Search, Library, Premium 4개의 BottomTab이 있고 각 탭 안에서 Stack 스크린이 쌓이는 구조로 되어있다. 개인적으로 생각해도 BottomTab을 베이스로 Stack 스크린이 쌓이는게 맞다고 판단이 되어서 위와같이 작성했다. listeners 부분은 이미 Home Tab이 focus 되어있는 상태일 때 다시 터치될 때 Home Tab 내에서 쌓인 Stack이 다시 Stack의 최상단(initialRoute인 HomeRoot)로 돌아오기 위해 작성했다.
export default function App() {
  return (
    <Provider store={store}>
      <SafeAreaView style={styles.container}>
        <StatusBar barStyle="light-content" />
        <Tabs />
      </SafeAreaView>
    </Provider>
  );
}
  • 이렇게 App.js는 최상단 Navigator Tabs 컴포넌트가 자리하고 있고, Redux를 통한 전역상태 관리를 위한 Provider로 감싼 형태로 작성했다.

[2] State feat. Redux & Navigation route.params

  • 이번 프로젝트에서 처음으로 제대로 Redux를 사용한 전역상태 관리를 해보기로 했다. 기본적으로 한 컴포넌트에서만 state를 사용하거나 그 컴포넌트와 직접적으로 연결되는 하위 컴포넌트의 경우 React에서와 같이 props로 넘겨주는 방식으로 작성했고, navigation으로 연결된 경우 react navigation의 route.params 객체를 통한 state 전달 방식으로 진행했다.
  const goToListDetail = (id, data) => {
    navigation.navigate('ListDetail', { id: id, data: data });
  };
  • 위와 같이 Stack navigator 의 한 screen에서 다른 stack screen으로의 이동을 저런식으로 처리하게 되는데, 앞부분의 'ListDetail' 은 위에서 내가 지정해준 원하는 screen 지정명이고 뒷부분은 route.params에 담겨 다음 컴포넌트에 전달해줄 수 있다.
export default function ListDetail({ navigation, route }) { 
  const { id, data } = route.params; 
  //생략 
  • 새로이 불려올 Stack screen 컴포넌트에서 route를 props로 받아온 후에 route.params.id나 위와같은 비구조화 할당 후 사용할 수 있다. HomeRoot 스크린에서 플레이리스트를 눌러 상세 스크린을 띄울때 route.params를 사용했다.

위와 같은 상황을 제외한, 예를들어 나의 경우 여러겹 쌓인 Stack screen에서 선택한 음악을 최상단 Tabs 컴포넌트에 위치한 음악 플레이어 부분에서 불러와야하는 경우 (대체적으로 여러 곳에서 사용되어야 하는 state나 거리가 먼 하위 컴포넌트에서 상위 컴포넌트에 쓰일 state를 업데이트해야하는 경우 Redux를 통한 전역 상태 관리로 처리했다.)

  • Redux의 actions와 reducers를 나눌때에는 처음에는 BottomTab 기준으로 나눠서 관리를 할까 생각했지만 진행을 하다보니 Home Tab에서 like한 것을 Library Tab에서 사용될 것을 생각하고 기능 위주로 분할해서 진행했다.

[3] Screen

> HomeTab

  • Home Tab의 initial Stack screen은 생각보다 간편하게 되어있어서 UI를 만드는 것 까지는 정말 쉬웠다. 오히려 Spotify Api를 사용해 정보를 불러오는게 생각보다 귀찮게 되어있어서 시간이 많이 걸렸다.
import { spotify } from './config/server';

export const TOKEN_REQUEST_API = 'https://accounts.spotify.com/api/token';

export const TOKEN_AUTH = `Basic ${base64.encode(
  spotify.ClientId + ':' + spotify.ClientSecret
)}`;

const BASE_URL = 'https://api.spotify.com/v1/';

export const instance = axios.create({
  baseURL: BASE_URL,
});

export const SEARCH_URL = 'search?query=';
export const CATEGORY_URL = 'browse/categories?country=KR';
  • Spotify Api를 사용해서 정보를 불러오는 것은 처음에 토큰을 먼저 불러오고 모든 요청의 headers 부분에 토큰값을 함께 보내주어야 정보를 가져올 수 있게 되어있었다. 기본적으로 요청은 거의 비슷한 형식을 가지고 있었기 때문에 기본 base 요청부분을 config.js 안에 axios instance를 활용하여 저장해주었다.
  useEffect(() => {
    getToken();
  }, []);

  useEffect(() => {
    token.length !== 0 && getCategory();
  }, [token]);

  const getToken = async () => {
    await axios(TOKEN_REQUEST_API, {
      headers: {
        Authorization: TOKEN_AUTH,
      },
      data: 'grant_type=client_credentials',
      method: 'POST',
    })
      .then(tokenRes => {
        dispatch(fetchToken(tokenRes.data.access_token));
        instance.defaults.headers.common[
          'Authorization'
        ] = `Bearer ${tokenRes.data.access_token}`;
      })
      .catch(err => {
        console.log('token err', err);
      });
  };

  const getCategory = async () => {
    const res = await instance.get(CATEGORY_URL);
    setCategories(res.data.categories.items);
    await getPlaylistsData(res.data.categories.items);
  };

  const getPlaylist = async id => {
    const res = await instance
      .get(`browse/categories/${id}/playlists?country=KR`)
      .catch(err => null);
    return res;
  };

  const getPlaylistsData = async categories => {
    const playlistRequest = categories?.map(category =>
      getPlaylist(category.id)
    );

    await axios.all(playlistRequest).then(
      axios.spread((...responses) => {
        for (let i = 0; i < responses.length; i++) {
          setPlayLists(prevPlayList => {
            return {
              ...prevPlayList,
              [categories[i].id]: responses[i].data.playlists.items,
            };
          });
        }
      })
    );
  };
  • 위와 같이 가장 처음에 불려올 컴포넌트에서 마운트시 토큰을 요청하는 요청을 먼저 보내고, 거기에서 instance.defaults.headers.common을 사용해서 모든 요청에 필요한 headers 부분을 axios instance에 추가해주었다. 그래서 결과적으로 아래에 getCategory와 getPlaylist의 경우 코드가 간결하게 적혀있는것을 볼수있다. (axios instance를 몰랐을 때는 쓸데없이 반복되게 다 적...)

플레이리스트 데이터를 요청하기 위해서는 카테고리 정보를 먼저 불러온뒤 그 카테고리의 각 id를 통해 그 카테고리에 속한 플레이리스트를 불러올 수 있게 되어있어서 getPlaylistData 함수를 통해 불러온 카테고리를 요청 배열로 바꾼뒤 axios.all을 통해 모든 요청을 한꺼번에 받아올 수 있게 처리하였다.

  • Spotify Api를 통해 정상적으로 카테고리와 플레이리스트 데이터를 불러오게 된 뒤에는 처음의 최적화가 문제였다. 애초에 토큰요청 => 카테고리 데이터 요청 => 각 카테고리의 모든 플레이리스트 데이터를 순서대로 요청해야 했고 또 생각보다 많은 정보를 받아 왔기 때문에 스플래시 스크린이 4초로 되어있어도 카테고리명만 불려온 화면이 나를 반겼었다.

  • ScrollView의 경우 보이지 않는 곳의 정보도 한번에 다 불러오는데 그 부분에서 아래의 다른부분도 렌더링을 진행 하느라 더 느리게 뜨고 바둑판식으로 랜덤하게 플레이리스트 커버 이미지가 떴었다. 최적화를 위해 ScrollView로 작성되어 있던 부분도 FlatList로 변경하고 initialNumToRender={2}maxToRenderPerBatch={4} 같은 옵션을 주어서 위에 보이는 이미지와 같이 로딩 이후 보이는 부분의 렌더링이 잘 되어잇는 것을 확인할 수 있다.


> ListDetail & SongList

  • HomeRoot에서 플레이리스트를 선택하면 이동이 되고 ListDetail Stack에서 SongList (Stack w/ mode Modal)로 접근 가능하다. 처음에는 SongList 컴포넌트 안에서 FlatList로 onEndReached props를 통해 잘라서 불러오게 만들었었는데, ListDetail에서 플레이리스트에 속한 음악의 제목과 가수명을 불러와서 보여주는 부분이 있고 랜덤재생 버튼의 동작을 위해 ListDetail에서 트랙 데이터를 불러오고 SongList로 route.params를 통해 전달해 주었다. 그래서 정보가 이미 다 있는 상태에서 무한 스크롤과 비슷하게 처리만 해주었다.

(작성하면서 생각해보니 랜덤재생 버튼은 redux thunk를 사용해서 처리하고 ListDetail에서 정보를 잘라서 받아오고 SongList에서 onEndReached 마다 요청을 보내는 무한스크롤을 하면...?)


> Expo AV (expo audio)로 음악 재생

youtube 링크 (소리주의)

  • Expo AV 라이브러리를 사용해서 음악을 재생하고 재생목록 전체를 받아와서 인덱스로 접근하기 때문에 다음곡 이전곡으로 넘어갈 수 있다. 라이브러리를 통해 sound라는 것을 생성하고 status에 접근이 가능해져서 status의 durationMillis와 positionMillis를 사용해 현재재생시간과 재생현황을 bar로 (width값을 조정) 표현했다.
  useEffect(() => {
    if (sound) sound.unloadAsync(); // unload
    playSound();
  }, [currentIndex]);
	// 인덱스가 변할때 sound를 unload 해줘야 다음곡 이전곡 변경을 했을때 이전곡이 멈추고 제대로 다음선택곡이 재생된다. 이 작업을 안해주면 첫곡 다음부터는 소리가 겹쳐서 재생된다.

  useEffect(() => {
    playController();
  }, [playButton]);
	// 리덕스로 관리되는 playButton 상태값을 통해서 bottomTab 뿐만 아닌 노래 디테일 모달에서도 일시정지 및 재생 관리가 가능해진다.

  const playerStatus = async status => {
    if (!status.isLoaded) {
      if (status.error) {
        console.log(`Error on expo AV: ${status.error}`);
      }
    } else {
      dispatch(setStatus(status.isPlaying));
      // 이 dispatch로 상태값을 업데이트 함으로써 현재 재생 상태를 여러곳에서 확인할수있다.
      setDuration(status.durationMillis);
      setPosition(status.positionMillis);

      if (status.didJustFinish) {
        dispatch(setNextMusic());
        // status에서 제공되는 didJustFinish boolean 값을 통해 자동으로 현재곡이 끝날시 다음곡이 재생되게 설정할 수 있다.
      }
    }
  };

  const playSound = async () => {
    const { sound } = await Audio.Sound.createAsync(
      { uri: currentMusic?.track?.preview_url },
      { shouldPlay: true },
      // 이 곳에서 sound를 생성하고 shouldPlay 를 true로 줌으로써 노래를 선택해서 보내주자마자 자동으로 재생상태로 시작된다.
      playerStatus
    );
    setSound(sound);
  };

  const playController = async () => {
    if (!sound) {
      return;
    }
    isPlaying ? await sound.pauseAsync() : await sound.playAsync();
  };
// 이 함수에서 노래의 재생 상태를 변경 가능하다.

  const getProgress = () => {
    if (sound === null || duration === null || position === null) return 0;
    let barStatus = Math.floor((position / duration) * 100);
    dispatch(setBarStatus(barStatus, position, duration));
    return barStatus;
  };
// 이 함수에서 width 값을 구해서 지정해 줌으로 현재 재생위치를 표현할수 있다.

Home Tab과 음악재생 그리고 Redux의 사용이 이번 프로젝트의 목표 였기 때문에 거의 저렇게만 구현하는데에 프로젝트 기간의 반정도를 소요한것 같다. 개인 프로젝트 였던 만큼 문제를 직면하면 일단 구글 검색에 공식 문서 살펴보기로 시간도 많이 보냈다. (같이 남아 개인 프로젝트 진행하면서 많은 도움을 주신 라이현님 감사합니다..)
막상 프로젝트를 진행하는 동안 막막하기도 했지만 이번 개인 프로젝트로 조금 더 혼자 해결할 수 있다는 자신감이 더 생긴것같다.


나머지 부분은 후에 작성하기로..

좋은 웹페이지 즐겨찾기