React Native 적용기

2022년 2월 18일
Template Library

Template Library 시리즈의 다섯 번째 글은 Template Library에 React Native를 적용한 이야기입니다.

작년 7월, 앞선 스터디 주제를 끝내고 다음 주제로 선정할 것들을 물색했었습니다. 당시 필자는 개인적으로 Real World API를 바탕으로 Server와 Web을 작성하고 App을 작성하다 중단한 상태였습니다. 같이 스터디를 하시는 분들은 React와 Next.js에 관심이 있어서, 그분들은 React와 Next.js로 필자는 React Native로 Real World 스펙을 구현하는 것으로 주제를 잡았습니다.

그렇게 몇 개월의 시간이 지났고, 2월의 마지막까지만 해당 주제로 진행하고 3월에는 새로운 주제로 넘어가는 시기가 되었습니다. 바쁜 회사 일과 다양한 이유로 빠르게 진행하지 못했었지만, 이번에는 마무리를 지어 종료하자는 마음으로 달려왔고, 이 글을 마지막으로 정리를 하고자 하였습니다.

관련된 내용은 이 저장소에서 확인하실 수 있습니다.

React Native

요새 핫한 Flutter도 있고 다른 프레임워크들도 있는데 React Native를 선정한 이유는 이전 주제들과 동일하게 실무적으로 가장 많이 사용했던 기술을 한번 정리하고자 하는 생각에서였습니다. 앞선 Next.js를 적용했을 때와 달리 이번에는 별달리 다른 큰 기능을 가져다 쓰지는 않았고 Styled Component만 추가하여 사용하였습니다.

.
├── android
├── ios
├── src
│   ├── App.tsx
│   ├── api
│   ├── assets
│   ├── components
│   ├── constants
│   ├── data
│   ├── enums
│   ├── env
│   ├── hooks
│   ├── interfaces
│   ├── libs
│   ├── navigators
│   ├── stores
│   ├── style.d.ts
│   └── utils
├── app.json
├── babel.config.js
├── index.js
├── jest.config.js
├── metro.config.js
├── package.json
├── react-native.config.js
├── tsconfig.eslint.json
└── tsconfig.json

전반적인 디렉터리 구조는 다른 이전 프로젝트와 동일하게 상단과 같은 구조로 작성하였습니다. React Native에서 전반적으로 Web과 동일한 구조를 가지고 가도 개발의 문제는 없고, 오히려 같은 구조로 인해 유지보수에 좀 더 용이한 면이 있어 선호하는 구조입니다. 조금의 차이점이 있다면 Web에서는 pages를 기반으로 Routing을 한다면 여기서는 navigators에 Routing에 대한 정의를 작성해 두었다는 점 정도일 것 같습니다.

문제 해결

사실 전반적인 React Native의 소개나 사용에 대해서 다 열거하는 것은 개인적으로 큰 의미가 없을 것으로 생각하여, 나중에 참고하고자 만났던 문제들에 대해 간단히 정리하여 기록으로 남겨두고 합니다.

설정 이슈

이전 [React Native] 설정 기록 자세하게 정리한 내용입니다. 전반적으로 Mono Repo를 구성함으로써 경로 문제나, 초창기의 Silicon Mac에 안정화되지 못한 단계에서 맞닥뜨린 문제들이 있었습니다. Mono Repo로 인한 경로 이슈는 App만 hoisting을 하지 않도록 설정하고, 전반적인 프로젝트에 경로에 대한 설정을 함으로써 해결하였고, Silicon Mac에 대한 부분도 리서치와 해결 방법을 찾았지만, 현재는 별도의 해결 없이도 해결된다고 들었습니다. 더 자세한 내용은 앞선 링크를 통해 확인하실 수 있습니다.

통신 이슈

안드로이드에서 통신 설정을 하여도 로컬 서버랑 통신 시 Network Error가 발생하는 경우가 생겼습니다. 이를 해결하기 위해 다음과 같이 adb reverse를 사용하여, usb 포트를 통해 휴대폰 기기의 로컬을 개발 기기의 로컬로 바꾸어 사용하였습니다. 이를 통해 로컬 서버에 모바일이 접근할 수 있도록 하였습니다.

$ adb devices
$ adb -s [emulator] reverse tcp:[port] tcp:[port]

Pagination 이슈

Real World 스펙은 Web 기준의 페이지 기반의 API들로 구성되어 있습니다. 하지만 App에서는 페이지보다는 무한스크롤로 구성하는 편이 조금 더 괜찮은 UX라고 생각하였습니다. 기존의 page 기반에서도 무한스크롤을 처리하지 못하는 것은 아니나, 새로운 글이 등록되는 등의 이슈로 인해 무한스크롤에 좀 더 어울리게 이번 기회에 커서로 조회할 수 있도록 작업을 하였습니다.

필자가 구성한 서버는 Sequelize를 사용하고 있기 때문에 다음과 같은 유틸을 추가하였습니다. 전달한 Dto에 있는 type을 보고 페이지 혹은 커서 기반으로 동작할 수 있도록 옵션값을 반환합니다.

export function getListOptionOfListDto(dto: ListDto): { limit: number; offset?: number; where?: WhereOptions } {
  if (dto.type === ListType.CURSOR) {
    return {
      limit: dto.limit,
      ...(dto.cursor && {
        where: {
          id: {
            [Op.lt]: dto.cursor,
          },
        },
      }),
    };
  }

  return {
    offset: (dto.page - 1) * dto.limit,
    limit: dto.limit,
  };
}

주어진 옵션으로 서비스에서 조회할 수 있는 메서드를 만들고, 다음 커서(nextCursor)를 포함하여 반환하도록 작성하였습니다.

async getArticles(
  getArticlesDto: GetArticlesDto,
  currentUserId: number | null,
  options?: SequelizeOptionDto,
): Promise<ArticlesDto> {
  const listDto = new ArticlesDto();
  let rows: Article[];

  listDto.list = [];

  if (getArticlesDto.type === ListType.PAGE) {
    const result = await this.articleRepository.findAndCountAll(getArticlesDto, options);
    listDto.count = result.count;
    rows = result.rows;
  } else {
    rows = await this.articleRepository.findAll(getArticlesDto, options);
    listDto.nextCursor = rows[rows.length - 1]?.id;
  }

  for (const row of rows) {
    const dto = await this.ofArticleDto(row, currentUserId, options);
    listDto.list.push(dto);
  }

  return listDto;
}

React Native에서는 FlatList를 사용하여 손쉽게 무한스크롤을 구현하였으며, 조회 및 갱신 등에 대한 처리는 React Query의 useInfiniteQuery를 사용하였습니다.

const fetchArticles = ({ pageParam }) =>
  type === ArticleType.FEED
    ? ArticleAPI.getFeedList(CONTEXT, {
        type: 'CURSOR',
        cursor: pageParam,
        limit: ARTICLE_PAGE_LIMIT,
        ...params,
      })
    : ArticleAPI.getList(CONTEXT, {
        type: 'CURSOR',
        cursor: pageParam,
        limit: ARTICLE_PAGE_LIMIT,
        ...params,
      });
const articleListResult = useInfiniteQuery<ListResult<IArticle>, unknown, ListResult<IArticle>>(
  queryKey,
  fetchArticles,
  {
    getNextPageParam: lastPage => lastPage.nextCursor,
    staleTime: 1000 * 60 * 5,
  },
);

const FeedList: FC<PropsType> = ({ articleListResult, toggleFavorite, moveToArticle, moveToAuthor }) => {
  const articles: IArticle[] =
    articleListResult.data?.pages.reduce(
      (acc: IArticle[], page: ListResult<IArticle>) => [...acc, ...page.list],
      [] as IArticle[],
    ) ?? [];
  return (
    <Container>
      {articleListResult.status === 'loading' ? (
        <Loading size="large" />
      ) : articleListResult.status === 'error' ? (
        <Empty>{articleListResult.error?.message}</Empty>
      ) : (
        <FlatList
          data={articles}
          renderItem={({ item }) => (
            <Feed
              article={item}
              toggleFavorite={toggleFavorite}
              moveToArticle={moveToArticle}
              moveToAuthor={moveToAuthor}
            />
          )}
          keyExtractor={item => item.id}
          ItemSeparatorComponent={() => <Separator />}
          refreshing={articleListResult.isFetchingPreviousPage}
          onRefresh={() => articleListResult.refetch()}
          onEndReached={() => {
            if (!articleListResult.hasNextPage) {
              return;
            }
            void articleListResult.fetchNextPage();
          }}
          onEndReachedThreshold={0.5}
        />
      )}
    </Container>
  );
};

다른 영역을 포함하는 무한스크롤 이슈

다음과 상단의 콘텐츠가 있고, 하단에 무한스크롤이 구성된 경우에 상단을 포함하여 스크롤을 처리하고자 FlatList 위에 ScrollView를 잡게되면 다음과 같은 에러를 조우하게 됩니다.

VirtualizedLists should never be nested inside plain ScrollViews with the same orientation - use another VirtualizedList-backed container instead.

아티클 상세

아티클 상세

이를 해결하기 위해서는 다음과 같이 상단 영역을 ListHeaderComponent에 작성해준다면 해당 영역을 포함하여 스크롤이 잡히도록 구성할 수 있습니다.

<FlatList
  ListHeaderComponent={
    <>
      {!articleResult.isLoading && articleResult.data ? (
        <ArticleTemplate
          user={userStore.user}
          disabled={loading}
          self={userStore.user?.username === articleResult.data.author.username}
          article={articleResult.data}
          moveToAuthor={handleMoveToAuthor}
          toggleFollow={handleToggleFollow}
          toggleFavorite={handleToggleFavorite}
          onDelete={handleDeleteArticle}
          moveToEdit={handleMoveToEdit}
        />
      ) : (
        <Loading size={'large'} />
      )}
      <CommentInputTemplate user={userStore.user} onCreate={handleCreateComment} />
    </>
  }
  ListFooterComponent={
    <>
      <CommentFooterTemplate commentListResult={commentListResult} />
    </>
  }
  data={comments}
  renderItem={({ item }) => (
    <Comment
      user={userStore.user}
      comment={item}
      moveToAuthor={handleMoveToAuthor}
      onDelete={handleDeleteComment}
    />
  )}
  keyExtractor={item => item.id}
  refreshing={commentListResult.isFetchingPreviousPage}
  onRefresh={() => commentListResult.refetch()}
  onEndReached={() => {
    if (!commentListResult.hasNextPage) {
      return;
    }
    void commentListResult.fetchNextPage();
  }}
  onEndReachedThreshold={0.5}
/>

마치며

더 다양한 이슈도 있었던 것 같지만, 기억에 깊게 남은 부분을 위주로 정리하였습니다. 사실 React Native의 진정한 정수는 지금 작성한 로직 같은 부분보다는, 심사나 배포 등의 운영 측면이 더 고민과 노력이 많이 필요한 부분이라고 생각해서, 다소 완성도가 떨어지거나 어색한 동작이 있는 등의 미흡한 부분이 있지만, Real World의 전반적인 스펙을 구현해서 정리했다 정도의 만족감을 가지고 종료하고 합니다.

Recently posts
© 2016-2023 smilecat.dev