Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

부귀영화

Chimha 프로젝트 회고 + 문제 해결 경험.. 본문

개발/프로젝트

Chimha 프로젝트 회고 + 문제 해결 경험..

Jinhoda 2022. 3. 17. 20:22

침투부 카페

작년 11월, 침착맨 팬 카페에서 어떤 분이 만드신 어플 디자인을 보고 만들기 시작했다. 하지만 당시에는 동아리 WIT에서 한창 Mount 앱을 개발 중이었고, 기말고사도 남아있어 프로젝트가 마무리되면 바로 시작하기로 마음 먹었다. 그렇게 2월 12일 동아리 최종 발표가 끝나고 곧바로 만들기 시작하여 3월 15일 앱스토어 등록 심사 중에 있다. 

+ 3월 17일 심사 거부가 떴다.. 또르르 💧

 

 

 

Mount 앱이 거의 4개월 동안 개발했던 것을 생각하면 정말정말 생각했던 것보다 빠른 시간 내에 만들었다. 방학이어서 개백수였던 것과 여러가지 신기술(Typescript, React Query)의 도입, 그리고 이전 Mount 개발하면서 쌓인 노하우들이 기간 단축이 도움이 되었다.

+ Boilerplate.. ✨

 

화면은 유튜브 화면, 트위치 화면, 웹툰 화면, 카페 화면, 스토어 화면으로 순서대로 구현했다. 

 

 # 유튜브 화면

상단의 YouTube 컴포넌트의 '침착맨', '침착맨+', '침착맨 원본 박물관' 버튼을 누르면 해당 유튜브 채널로 이동한다. React Native에서 기본적으로 제공하는 Linking을 이용해서 구현해서 만약 유튜브 앱이 있어서 앱을 통해 열 수 있으면 앱을 열고, 만약 그렇게 할 수 없다면 브라우저를 통해 열리도록 구현했다. 

 

상단에 위치하게 되는 채널들은 이미 정해져있기 때문에 썸네일, 채널명, 채널 설명 등을 미리 저장해서 쓸 수 있었지만, 채널 id만을 저장해서 Youtube Data API에 그때그때 요청해서 불러오는 방식을 선택했다. 현재 침착맨 플러스라는 이름의 채널이 긴착맨, 짤착맨, 침착맨 더보기, 침착맨+ 로 변모해왔기 때문에... 

 

하단에 Subcontents 컴포넌트에는 침착맨이 출연하는 다른 유튜브 채널들을 보여준다. 위의 방식과 동일하게 유튜브 채널의 id를 저장해두고 그때그때 불러와서 보여주도록 구현했다. 2열로 보여주기 위해 FlatList에 column을 2로 지정하여 구현하였다. 

 

 

# 트위치 화면

만약 현재 스트리밍 중이라면 방송 중인 화면이 이미지로 보여지고, 오프라인이라면 저 이미지를 보여준다. 클릭하면 침착맨 트위치 채널로 이동한다. 유튜브 화면과 비슷하게 ID를 저장하고 Twitch API를 사용해서 구현했다.

 

하단의 Twitch Crew 컴포넌트는 배도라지 멤버들의 트위치 채널들을 보여주며 클릭하면 이동한다. 

 

# 웹툰 화면

침착맨이 이말년 시절 연재하던 네이버 웹툰들을 보여준다. 클릭했을 때 네이버 웹툰 앱이 있다면 앱을 열고 해당 웹툰으로 이동한다. 제일 구현하기 쉽다고 생각했지만 의외의 곳에서 고전했는데 바로 네이버 웹툰의 Url Scheme(fb455753897775430://)은 알고 있지만 특정한 웹툰 ID로 이동할 수 있는 방법을 알아낼 수 없었던 것이다. 

twitch://stream/${value.data?.data[0].login}

트위치 앱은 위와 같이 /stream/{channel_login_id}로 해당 채널로 이동할 수 있다고 공식 문서에 자세히 설명되어있다. 하지만 네이버 웹툰은 이러한 Deep Links Formats을 설명하는 공식 문서가 없었다. 첫 번째로 시도해본 방법은 무차별 대입이었다. 네이버 앱 URL Scheme연동 가이드 참고해서 fb455753897775430://명령어?파라미터 형식에 맞추어 아래와 같은 방법을 시도해보았다.

fb455753897775430://list?titleid=790453

네이버 웹툰 url을 참고해서 위의 포맷 말고도 여러개를 시도해봤으나...

전부 실패로 돌아가고 다른 방법을 고민했다. 그러던 중 모바일에서 네이버 웹툰에 접속하면 앱으로 열기 버튼이 있는 것을 기억하고 곧바로 확인해보았다. 

역시 웹툰앱으로 이동할 수 있는 요소가 있음을 확인했고 어떤 방식으로 앱을 여는지 분석해보았다.

안드로이드의 경우 아래와 같이 intent scheme을 사용하는 것 같다.

webtoonkr://contentList?version=2&titleId=769209&league=WEBTOON

ios도 위와 비슷할 것이라고 생각해서

315795555://contentList?version=2&titleId=769209&league=WEBTOON

로 시도해보았으나 실패...

 

다음으로 data-ios-universal은 무엇인지 궁금했는데 https://developer.apple.com/ios/universal-links/ 를 참고해보니 Apple이 웹사이트를 거쳐 iOS 앱을 열 수 있게 하는 방식이라고 한다. 클릭했을 때는 data-ios-scheme-query가 뒤에 붙여져서 접속되었다. Deep Link로 https://apps.comic.naver.com/launchApp/contentList?version=2&titleId=704595&league=WEBTOON 를 해봤더니

마침내 웹툰 앱을 실행하면서 해당 titleId를 가진 웹툰으로 이동했다! 

 

URL scheme은 정말 웹툰앱을 '실행'만 시킬 수 있던 것일까?... Naver Developer Forum에도 물어봤지만 답변이 달리지는 않았다. 만약 나중에 네이버에 들어간다면(....) 아니 들어갈 수 있다면 어떻게 사용할 수 있는지 찾아보고 다른 개발자들이 참고할 수 있게 오픈하고 싶다. 나같은 사람이 있을 수 있으니

 

모바일에서 네이버 웹툰에 접속했을 때 앱으로 열기 버튼이 있다는 것을 생각해서 문제를 해결한건 좀 뿌듯했다 ☺️

 

 

# 네이버 카페 화면

구현하기 전부터 제일 어려울 것이라 생각했던 화면이다. 네이버 카페는 글 불러오는 API가 없어서(가입/글쓰기만 있음) html 파일을 불러와서 파싱하고 Promise 객체로 넘겨서 React-Query의 InfiniteQuery를 사용해 사용자가 FlatList의 끝으로 스크롤하면 다음 페이지 데이터를 요청해서 추가하도록 구현해야 했다

 

요구사항이 상당했고 구현 과정에서도 애로사항이 많았다. 

 

먼저 네이버 카페 크롤링부터 살펴보자

개발자 도구의 네트워크 탭에서 https://cafe.naver.com/ArticleList.nhn?search.clubid=29646865&userDisplay=30&search.boardtype=L&search.cafeId=29646865&search.page=1&search.menuid=1 로 문서를 불러오는 것을 확인했다. search.clubid로 카페를 구분하고 이외의 옵션으로는 몇 개의 글을 불러올 지를 결정하는 search.userDisplay, 게시글들을 보여주는 방식을 나타내는 search.boardtype, 앞의 clubid와 동일한 search.cafeId, 페이지네이션을 나타내는 search.page, 게시판 종류를 구분하는 search.menuid 등이 있었다. 그렇다면 search.clubid, search.userDisplay, search.boardtype, search.cafeId는 고정하고 페이지와 게시판 종류를 달리하여 요청하면 일반적인 게시판 기능을 구현할 수 있을 것이다. 그래서 테스트를 위해 postman을 활용해서 해당 url로 GET 요청을 보냈더니....

수많은 인코딩 오류.....

음.. Response Headers를 확인해보니 content-type이 text/html;charset=ms949였다. 따라서 axios로 받아온 데이터를 iconv로 decode해야 정상적인 html 파일을 받을 수 있었다.

const getHTML = async (url: string): Promise<string> => {
  const response = await axios.request({
    method: "GET",
    url: url,
    responseType: "arraybuffer",
  });

  let html = response.data;
  let data = Buffer.from(html);
  return iconv.decode(data, "cp949");
};

ms949가 아닌 cp949를 사용한 이유는 ms949는 자바에서 사용하는 한글 확장 완성형 인코딩 방식이었기 때문이다. 아마 네이버에서 Spring으로 서비스하기 때문에 그렇게 온 것 같다. iconv로 dcode 하기 전에 string인 html 변수를 Buffer로 변환한 이유는 decode 함수가 Buffer형만 받았기 때문이다. 이렇게 정상적인 html 파일을 받아고 나서는 아래와 같이 cheerio로 크롤링해서 반환했다. 

export const getArticles = async (
  menuId: number,
  pageId: number,
): Promise<FetchArticle> => {
  const url = `${BASE_URL}/ArticleList.nhn?search.clubid=29646865&userDisplay=50&search.boardtype=L&search.cafeId=29646865&search.page=${pageId}&search.menuid=${menuId}`;
  const html = await getHTML(url);
  const $ = await cheerio.load(html);
  const trs = $("#main-area > div:nth-child(6) > table > tbody > tr");

  const data = trs
    .map((index, element) => {
      return {
        title: $(".article", element).text().replace(/\s+/g, " ").trim(),
        author: $(".m-tcol-c", element).text(),
        date: $(".td_date", element).text(),
        link: "https://m.cafe.naver.com" + $(".article", element).attr("href"),
      };
    })
    .get();

  return {
    result: data,
    page: pageId,
  };
};

menuId와 pageId를 입력 받아 해당하는 게시글 목록을 반환하는 getArticles 함수이다. 각 게시글의 title, author, data, link를 data에, 현재 pageId를 page에 넣어 반환했다. page 프로퍼티를 포함한 이유는 이후 React Query의 Infinite Query를 사용할 때 getNextPageParam 옵션을 채우는데 필요하기 때문이다.

  const [category, setCategory] = useState(0);

  const profile = useQuery("profile", getProfile);

  const posts = MENU_IDS.map((menu) => {
    return useInfiniteQuery(
      menu.category,
      ({ pageParam = 1 }) => getArticles(menu.id, pageParam),
      {
        getNextPageParam: (lastPage, pages) => {
          return lastPage.page + 1;
        },
      },
    );
  });

이후 CafeScreen.tsx에서 위의 api 코드를 불러와서 위와 같이 사용했다. category라는 상태를 만들었는데, 이는 여러 게시판의 글들을 전부 posts에 넣어서 사용했기 때문이다. posts는 각 게시판 마다 useInfinteQuery로 요청한 게시글들을 배열로 감싼 형태이다.

posts = [
    { // 게시판 타입 0
    	"data": {"pageParams": [undefined], "pages": [[Object]]}, 
        "fetchNextPage": [Function bound fetchNextPage], 
        "fetchPreviousPage": [Function bound fetchPreviousPage], 
        "hasNextPage": true, 
        ...
    },
    { // 게시판 타입 1
    	"data": {"pageParams": [undefined], "pages": [[Object]]}, 
        "fetchNextPage": [Function bound fetchNextPage], 
        "fetchPreviousPage": [Function bound fetchPreviousPage], 
        "hasNextPage": true, 
        ...
    },
    ...
]

대략 이런 느낌.. (Typescript에서 자동으로 타입 추론을 해줘서 따로 타입을 정의하지는 않았다)

그래서 posts에서 원하는 게시판의 글만 쏙쏙 꺼내오기 위해 category 상태를 만들고 아래와 같이 사용했다.

  {posts[category].data?.pages
    .map((page) => {
      return page.result;
    })
    .flat()
    .map((item, index) => {
      return (
        <Content
          post={item}
          scrollToLastPosition={scrollToLastPosition}
          setLoading={setLoading}
          key={index}
        />
      );
    })}

posts에서 category에 해당하는 useInfinteQueryResult 객체의 data에서 pages를 불러와 result 만을 뽑아와서 flat으로 배열을 꺼내주고 map으로 돌려 Content 컴포넌트를 반환했다. 가독성은 좋지 않지만 짧은 코드로 구현했으니 만족해야 될지.. 아니면 길더라도 이해가 쉽게 할지 고민했다. 하지만 사용자에게 보여주고 싶은 게시판을 추가하고 싶을 때 단순히 MENU_IDS 배열에 추가하면 되니 이러한 방식으로 구현했다. 

 

이제 위의 코드를 ScrollView로 감싼 다음 사용자가 스크롤 해서 바닥에 닿는 것을 감지하면 posts 배열 중 해당하는 게시판 객체의 fetchNextPage를 호출하면 되는 것이었다. 그렇게 쉽게 마무리 할 수 있을 것이라 생각했지만... 바닥에 닿아서 fetchNextPage를 실행해 데이터를 추가하는 순간 ScrollView가 최상단으로 스크롤 되는 것이었다. 자세하게는 상태가 변하는 것을 react가 감지해 rerendering하게 되는 문제였다.... ScrollView의 옵션 중에서 Content가 변해도 position을 유지하는 maintainVisibleContentPosition을 설정했지만 해결되지 않았다. 열심히 구글링(Prevent scroll to top after rerender in React Native, How to prevent scroll to top when rerender component)을 해도 다른 사람들도 똑같이 겪고 있는 문제였다. 다른 개발자들이 내놓은 해결 방안으로는 onScroll을 통해 스크롤 마다 scrollPositionRef를 갱신하고 만약 바닥에 닿는 것을 감지하면 fetchNextPage를 실행하는 것이었다.

 

  const scrollViewRef = useRef<ScrollView>(null);
  const scrollPositionRef = useRef(0);
  const [loading, setLoading] = useState(f
  
  const isCloseToBottom = ({
    layoutMeasurement,
    contentOffset,
    contentSize,
  }: any) => {
    const paddingToBottom = 20;
    return (
      layoutMeasurement.height + contentOffset.y >=
      contentSize.height - paddingToBottom
    );
  };
  
    const scrollToLastPosition = () => {
    if (scrollViewRef.current != null) {
      scrollViewRef.current.scrollTo({
        x: 0,
        y: scrollPositionRef.current,
        animated: false,
      });
    }
  };

위와 같이 Ref를 선언하고 isClosetoBottom, scrollToLastPosition 함수를 정의했다. ScrollView의 props는 아래와 같이 작성했다.

<ScrollView
              ref={scrollViewRef}
              scrollEventThrottle={0}
              onScroll={async ({ nativeEvent }) => {
                scrollPositionRef.current = nativeEvent.contentOffset.y;
                if (isCloseToBottom(nativeEvent)) {
                  setLoading(true);
                  await posts[category].fetchNextPage();
                  setLoading(false);
                }
              }}
              onContentSizeChange={() => {
                if (scrollViewRef.current != null) scrollToLastPosition();
              }}
              refreshControl={
                <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
              }
            >

스크롤 마다 scrollPositionRef를 갱신하고, 만약 바닥에 닿는 것이 감지되면 Loading을 true로 바꾸고 데이터를 불러온 뒤 다시 Loading을 false로 바꾼다. onContentSizeChange는 내부 Content의 사이즈가 변경되면 실행되며 fetchNextPage로 데이터가 추가되었을 때 다시 원래 위치로 스크롤하도록 한다. loading 상태를 추가한 이유는 데이터를 불러오고 아래로 스크롤하는 과정을 숨기기 위함이다. Loading이 true일 경우 ActivityIndicator를 보여준다.

 

refreshControl은 ScrollView 맨 위에서 아래로 당겼을 때 실행되며 이때 새로고침을 수행하도록 구현했다.

  const onRefresh = React.useCallback(async () => {
    setRefreshing(true);
    await posts[category].refetch();
    setRefreshing(false);
  }, []);

위에서 구현한 방식과 유사하게 refetch를 수행한다.

 

이렇게 제일 힘들었던 네이버 카페 구현을 끝마쳤다.

 

# 스토어 화면

원래 디자인과 조금 다르게 구현했는데, 이는 굿즈 사이트에 침착맨만의 굿즈가 있는 것도 아니고 수시로 굿즈 구성이 달라져서 이를 매번 반영하기 어려울 것이라 판단했기 때문이다. 따라서 바로 굿즈 사이트로 이동할 수 있도록 보여주는 정도로만 구현했다. 

 

 

# 느낀점

좋아하는 것을 구현하니 엄청난 생산성을 발휘해서 미친 퍼포먼스로 빠르게 만들 수 있었다. 새로운 기술도 적극적으로 써보고, 까다로운 문제들도 있었는데 결국 해결도 했다. 문제들을 해결했을 때의 성취감과 기쁨을 자랑하고 싶었지만 혼자 진행한 프로젝트라.. 자축으로 끝내기는 아쉬워 블로그에라도 남겨본다. 

 

다음 프로젝트로는 스마트폰에서 웹 서핑 중에 단어(또는 글, 링크, 사진)을 꾹 눌러서 선택하고 바로 앱에 저장할 수 있는 어플을 만들 예정이다. 재밌겠다.

 

'개발 > 프로젝트' 카테고리의 다른 글

Chimha 배포 완료  (0) 2022.03.24
App Store 4.1.0 Design: Copycats  (0) 2022.03.18
React Navigation collapsible header 구현기  (0) 2022.03.09
Mount 프로젝트 회고  (2) 2022.03.07