트위치 멀티 뷰어 개발기

백승일·2023년 12월 21일
0
post-thumbnail

나는 트수다.

트위치에 하스스톤 밖에 볼게 없던 시절부터 트위치를 시청하는 나는

트위치의 시한부 선고에 통탄을 금치 못하였지만, 어쩌겠는가.

피하지 못하면 즐기라고 나는 트위치 마지막 자낳대를 최대한 즐기려 한다.

멀티 뷰어

먼저 자낳대는 리그 오브 레전드라는 게임을 스트리머들 끼리 진행하는 대회이다.

5명이 한 팀이 되고, 코치와 감독이 스트리머들과 함께 한다.

롤은 게임 안에서 동시 다발적으로 여러 이벤트가 일어나기 때문에, 한번에 한 명의 스트리머의 화면만 보면

아쉬운 부분이 있다. 그래서 한번에 5개의 화면을 볼 수 있게 개발할 것이다.

기술 스택

Next.js, SWR, styled-components

구조


내가 팔로우한 스트리머 목록을 가져오기 위해 트위치 로그인이 필요하기에 localstorage에 토큰이 없으면 로그인 모달을 띄우고 있으면 리스트와 뷰어를 보여주는 식으로 구조를 짰다.

사전작업

트위치는 open api로 여러 기능을 제공하는데, 사용자의 팔로우 목록, 트위치 뷰어 등의 유용한 기능이 많다. 이를 활용하기 위해 트위치 개발자 콘솔에 내 도메인을 등록해준다.

구현

하기 전에, 나는 서버나 db를 안쓰려고한다. 그러다 보니 localStorage에 대부분 저장하게 되는데, next.js에서 편하게 쓰려고 클래스 하나 만들고 시작한다.

export class LocalStorageClient {
  constructor() {}

  static getItem(key: string) {
    return typeof window !== 'undefined' ? localStorage.getItem(key) : null;
  }

  static setItem(key: string, value: string) {
    if (typeof window !== 'undefined') {
      localStorage.setItem(key, value);
    }
    return;
  }
}

로그인

일단 트위치 api를 사용하기 위해서 user-access-token을 발급받아야한다. 토큰의 발급은 OAuth방식으로 사용자의 트위치 아이디만 있으면 된다. 하지만 이런 과정을 사용자한테 구구절절 설명할 바엔 로그인이라는 명목으로 사용자가 익숙한 과정을 경험하게 해준다.

로그인 버튼을 누르면, 사용자 로그인 아이디를 localstorage에 저장하고 텍스트 개발자 콘솔에서 가져온 내 client_id와 리다이렉트 해줄 uri을 쿼리 스트링에 넣어서 router.push해준다.

const router = useRouter();
const twitchOauthUrl = `https://id.twitch.tv/oauth2/authorize?response_type=token&state=${process.env.NEXT_PUBLIC_TWITCH_STATE}&client_id=${process.env.NEXT_PUBLIC_TWITCH_CLIENT_ID}&redirect_uri=${process.env.NEXT_PUBLIC_TWITCH_REDIRECT}&scope=user:read:follows`;
const onClickLogin = async () => {
    if (userId === '') {
      alert('본인의 트위치 아이디를 입력해주세요');
      return;
    }

    LocalStorageClient.setItem('userId', userId);
    router.push(twitchOauthUrl);
 };

정상적으로 요청이 가면, 내가 입력한 리다이렉트 uri에 다가 access_token을 붙여서 다시 리다이렉트해준다. 이떄 받은 access_token을 localstorage에 저장해준다.

const get_query = path
        .split('#')[1]
        .split('&')
        .map(str => ({ [str.split('=')[0]]: str.split('=')[1] }));

      const access_token_obj = get_query.find(obj => obj['access_token']);
      const access_token = (access_token_obj || {}).access_token;
      if (access_token) {
        LocalStorageClient.setItem('access_token', access_token);
      }

그 마지막으로 팔로우 리스트를 가져오기 위해선 로그인한 사용자의 채널아이디를 알아야한다.

아까 로그인할 때 입력받은 유저의 아이디를 사실 이때 사용된다.

export const get_user_channel_id_from_id = () => {
  const userId = LocalStorageClient.getItem('userId') || '';
  return axios
    .get<{ data: userInfo[] }>(`https://api.twitch.tv/helix/users?login=${userId}`, {
      headers: {
        Authorization: `Bearer ${LocalStorageClient.getItem('access_token')}` || '',
        'Client-Id': process.env.NEXT_PUBLIC_TWITCH_CLIENT_ID || ''
      }
    })
    .then(res => res.data);
};

이렇게 요청을 보내서 온 데이터 안에 사용자의 채널 아이디와 사용자가 설정한 닉네임이 들어있다. 이것도 저장해주자.

get_user_channel_id_from_id()
        .then(res => {
          if (!res.data) return;

          if (res.data.length === 0) {
            alert('잘못된 정보입니다.');
            return;
          }

          const { id, display_name } = res.data[0];

          LocalStorageClient.setItem('display_name', display_name);
          LocalStorageClient.setItem('channel_id', id);
        })

자 이렇게 필요한 데이터는 츠쿠나 손가락 모으듯이 다 모았다. 이제 채널 리스트를 가져와보자

SWR을 이용한 채널 리스트

일단 이 서비스에서 가장 중심이 되는 컴포넌트는 이 채널 리스트 컴포넌트이다. 여기서 스트리머들의 리스트를 가져와야 하기 때문인데, 트수라면 알겠지만 트위치의 채널리스트는 시청자수에 따라서 주기적으로 업데이트 된다.

만약 이 데이터를 Redux같은 저장소에 저장한다면? 주기적으로 action을 쳐줘야할것이다. 하지만 우리에겐 SWR, react-query같은 좋은 라이브러리들이 있다. 이 둘중에 조금 더 코드가 짧고 간결한 SWR을 사용할 것이다.

const [selectedList, setSelectList] = useState<Channel_Info[]>([]);

<SWRConfig>
    <MultiTwitchChenelList onSelectChannel={onSelectChannel} selectedList={selectedList} />
</SWRConfig>

아까 구조 부분에서 봤듯이, 리스트 컴포넌트와 뷰어 컴포넌트는 같은 뎊스에 존재하기 때문에 상위 컴포넌트의 state로 선택한 채널 리스트를 관리해줘야 뷰어에서도 그 상태를 가지고 작업을 칠 수 있다.

이때 채널은 최대 5개까지만 선택할 수 있게 기획했으니 onSelectChannel함수에서 필터링해주자.

  const onSelectChannel = (channel_Info: Channel_Info) => {
    if (selectedList.length > 5) {
      return;
    }

    setSelectList(state =>
      state.find(channel => channel.user_id === channel_Info.user_id)
        ? state.filter(channel => channel.user_id !== channel_Info.user_id)
        : [...state, channel_Info]
    ); // 이부분은 이따 설명해야하니 일단은 선택된 채널아이디가 이미 선택된 상태면 뺴준다고 기억하자
  };

자 이제 채널들을 가져와 보자.

export const get_Follow_Streamers_With_Img = async () => {
  const follow_list = await get_Follow_channels_List();
  const streamer_ids = follow_list.data.map(streamer => streamer.user_login);

  const streamer_imgs = (await get_Streamer_Profile_img(streamer_ids)).data;
  const Follow_Streamers_With_Img = follow_list.data.map(streamer => ({
    ...streamer,
    profileImg: streamer_imgs.find(streamerInfo => streamerInfo.id === streamer.user_id)
      ?.profile_image_url
  }));
  
  return Follow_Streamers_With_Img as Channel_List_info[];
};
-------------------------------------------------------------------------------

const channel_id = LocalStorageClient.getItem('channel_id');

const { data: requestList } = useSWR(channel_id || null, get_Follow_Streamers_With_Img);

자 채널정보를 가져오는 플로우는 다음과 같다.

1. 사용자의 channel_id로 팔로우한 스트리머 정보를 가져온다.
2. 스트리머 정보에서 user_login정보를 가지고 스트리머들의 프로필 이미지를 가져온다. 

이 과정을 통해서 스트리머들의 정보와 프로필 이미지를 가져올 수 있었다.

CSS를 이용하여 스트리머의 상태 메시지가 너무 길면 말 줄임표가 나오게 작업해주고

채널 리스트를 펼치고 줄이는 기능도 className를 이용하여 구현해준다.

이제 여기까지 구현하면, 기획한 내용은 다 끝이난 것이다. 이제 뷰어 만들러 가자

Viewer

일단 트위치는 임베디드 뷰어를 지원해준다. 쉽게 말해서 그냥 html에 iframe넣고 src만 잘 넣으면 트위치 보듯이 보인다는 말이다.

    <MultiTwitchViewerStyle id='iframe_container' ref={containerRef}>
      {selectedList.map(channelInfo => (
        <div key={channelInfo.user_id} className='iframe_wrapper'>
          <iframe src={get_streamURL(channelInfo.user_login)} />
          <div className='select_wrapper'>
            <div className='btn' onClick={() => onOffChannel(channelInfo.user_id)}>
              닫기
            </div>
          </div>
        </div>
      ))}
    </MultiTwitchViewerStyle>

너무 간단하지 않은가!

하지만 뭐든 간단하면 그 안에 복잡한 뭔가 있는 것이다.

이 서비스 이름이 "멀티 뷰어" 인 만큼, 하나의 뷰어 컨테이너 안에 여러 Iframe태그들이 생기게 되는데,
ifarme은 css로 flexible한 스타일링이 안된다. ( 내가 알기로는)

그래서 화면이 늘어나면 자연스럽게 다른 화면 사이즈가 줄고 해야하는데, 그게 안된다. 골때리지만 어쩌겠는가!
개발자니까 문제 해결 능력을 발휘해보자.

먼저 나는 화면이 들어가는 컨테이너의 사이즈를 구했다. flexible한 스타일링이 안된다는 말은 결국 px로 떄려박아야한다는 소리! 디테일한 계산을 위해서 이다.

  const [containerSize, setContainerSize] = useState({
    width: 0,
    height: 0
  });
  
  const calcIframeSize = () => {
    const { width, height } = containerSize;
    const iframe_container = document.getElementById('iframe_container');
    if (!iframe_container) return;

    const iframe_containerRects = iframe_container.getClientRects()[0];

    const availableWidth = width || iframe_containerRects.width - 30;
    const availableHeight = height || iframe_containerRects.height - 20;

    if (width === 0 || height === 0) {
      setContainerSize({
        width: availableWidth,
        height: availableHeight
      });
    }
  };
  
  useEffect(() => {
    calcIframeSize();
  }, [containerSize, selectedList.length]);

위에서 컴포넌트 코드를 보여줬을 때, 눈치 빠른 사람은 봤겠지만, 최상위 컴포넌트에 id도 주고 ref도 줬다. 사실 둘 중 하나만 줘도 된다. 하지만 둘다 사용해보자. 이제 컨테이너의 사이즈는 구해졌다. 다음으로 컨테이너 내부의 iframe의 사이즈를 계산해서 주기만하면 된다.

계산 하기 전에, 먼저 레이아웃을 정해주자. 나는 다음과 같은 레이아웃을 짰다.
화면이 하나일때 부터, 5개까지의 레이아웃을 (매우)러프하게 잡고 화면의 비율을 정리했다.

export const VIEWPORT_RATIO = [
  [1, 1],
  [2, 1],
  [2, 2],
  [2, 2],
  [2, 2]
]; // 1~4개의 화면 비율

export const FIVE_VIEWPORT_RATIO = [
  [2, 2],
  [2, 2],
  [3, 2],
  [3, 2],
  [3, 2]
]; // 화면이 5개 일때

자 이제 화면의 사이즈가 이렇게 변경되는 조건을 생각해보자.
1. 선택된 화면의 갯수가 변경될 때.
2. 컨테이너의 사이즈가 바뀔 때

1번이야 뭐 useEffect의 dependency array에 선택된 화면 리스트의 길이 넣어주면 될 거같고, 2번이 까다로워 보인다.

컨테이너 사이즈의 변경을 뭔가 관찰하고 있다가 바뀌면 함수를 딱 실행 시켜주면 좋을 것 같은데..
라고 생각한 당신을 위해 ResizeObserver가 존재한다.

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
 	// 사이즈 변경된걸 감지해주는 관찰자 하나 두고
    const containerObserver = new ResizeObserver(entries => {
      const targetContainer = entries[0];
      const { width, height } = targetContainer.contentRect;

      const availableWidth = width - 30;
      const availableHeight = height - 20;

      setContainerSize({
        width: availableWidth,
        height: availableHeight
      }); // 변경되면 state를 업데이트 해준다.
    });
    containerObserver.observe(container);

    return () => {
      containerObserver.disconnect();
    };
  }, []);
  
   useEffect(() => {
    calcIframeSize();
  }, [containerSize, selectedList.length]); // state가 변경되면 iframe의 사이즈 다시 계산해준다.

자 이제 calcIframeSize함수를 수정해주자.

  const calcIframeSize = () => {
    // 아까 위의 코드와 동일

    const iframes = iframe_container.getElementsByTagName('iframe');
    if (iframes.length === 0) return;

    if (iframes.length === 5) {
      for (let iframeIdx = 0; iframeIdx < iframes.length; iframeIdx++) {
        iframes[iframeIdx].width = String(availableWidth / FIVE_VIEWPORT_RATIO[iframeIdx][0]);
        iframes[iframeIdx].height = String(availableHeight / FIVE_VIEWPORT_RATIO[iframeIdx][1]);
      }

      return;
    }

    const ratio = VIEWPORT_RATIO[iframes.length - 1];

    for (let iframeIdx = 0; iframeIdx < iframes.length; iframeIdx++) {
      iframes[iframeIdx].width = String(width / ratio[0]);
      iframes[iframeIdx].height = String(height / ratio[1]);
    }
  };

요기서 생각해 볼 점은 getElementByTagName으로 가져온 객체는 HTMLCollection이라는 점이다.

본인은 왜 forEach도 못쓰는 HTMLCollection을 가져왔을까. (몰랐나?)

우리는 목적을 잊으면 안된다. 우리의 목적은 iframe의 스타일을 변경하는 것이다. 스타일의 변경이란 실시간으로 이뤄져야하고, querySelecto로 가져오는 NodeList는 변경사항이 실시간으로 반영되지 않는다. 그래서 잘 안쓰던 for문으로 루프를 돌렸다.

자 이렇게 뷰어가 완성되었다.!
결과 영상 링크

이제 트위치의 마지막 자낳대를 즐겨보자..

멀티뷰어링크

profile
뉴비 개발자

0개의 댓글