React Native로 실제 서비스를 100% 구현 해보자 (1)

xedni·2021년 1월 16일
40

Project | MarketHolly

목록 보기
1/2
post-thumbnail

외로운 개인 프로젝트...
리액트 네이티브로 마켓컬리를 똑같이 만들 수 있냐고?
충분해... 하고 싶은거 다 할 수 있어!

1. 마켓컬리 분석

: 심플하고 깔끔한 UI를 가졌고 이커머스의 가장 기본적이고 중요한 제품 구매와 관련된 기능에 충실한 앱이다. 때문에 불필요한 기능이나 지저분한 뷰가 없고 Scene들이 군더더기 없이 깔끔하고 아름답다.
(마켓 컬리를 고른것은 아주 완벽한 선택이다)


2. 프로젝트 내용

  • 프로젝트 목적 :

이커머스 앱을 클론하여 실제 서비스에 대한 이해도를 높이고 클론 중 발생하는 이슈들에 대해 스스로 학습한다.

  • 추가 개인 목표 :

  1. 기능 연습이 아닌 실제 서비스 구현이라는 생각으로 완성도 100%를 실현해본다.
  2. 리액트 네이티브에 존재하는 다양한 라이브러리를 최대한 많이 사용해본다.
  3. 재활용 가능한 컴포넌트에 집중하여 하드 코딩 일절 없이 개발한다.
  4. 마켓컬리에는 없는 메거진 형태의 페이지도 1개 만들어 적용해 본다.(최근 트렌드 반영)
  • 개발 기간 :

2020년 12월 24일 - 2021년 1월 8일, 16일간

  • 구성원 :

1인 / Xedni(Front-End)


3. 사용된 기술

  • Development environment: Expo
  • Framework : React Native
  • DataBase : FireBase, AsyncStorage
  • Login : Google Login API
  • Library : React Navigation, Redux
  • Third-Part : React-native-mail, React-native-swiper... 등등

4. 실제 구현한 기능

: 카테고리를 제외한 전체 페이지와 주요 기능 전체 + 추가 페이지(메거진 페이지)

  • 레이아웃 및 디자인 : 실제 앱과 동일한 전체 페이지(카테고리 제외)
  • Google Login 기능(FireBase server 이용)
  • Swiper를 이용한 슬라이드 배너
  • Navigation을 이용한 스택 및 탭 네스팅
  • React-native-mail 를 이용한 문의하기 기능
  • Redux를 이용한 상품 상세 페이지 스택 기능
  • FlatList를 이용한 상품 리스트 기능
  • Filter, Includes를 이용한 상품 검색 기능
  • AsyncStorage를 이용한 장바구니 기능

5. Code Review

<클릭해서 영상으로 이동>

1) Navigation

: 앱은 스택이 많기 때문에 처음부터 구조화를 잘 하는 것이 중요하다.

  • 기본적인 5개의 하단 Tab, 그 중 홈 메뉴 안에 또 5개의 Tab, 스택 중 Detail 스택 페이지에 또 5개의 Tab, 그리고 장바구니, 글쓰기, 후기 리스트 등등 수 많은 스택 페이지들...
    이 많은 탭과 스택들 어떻게 네스팅 하는지가 관건이었다.
    (사실 처음에 Bottom을 부모로 만들었다가 열흘 이상의 시간을 소모했다. ~~덕분에 큰 깨닳음을 얻었다~~)

  • 클론이므로 크게 고민하지 않고 당장 보여지는 구조대로 만들어도 상관은 없지만 실제 서비스라면 언제 어떤 스택이 새로 만들어질지 모르기 때문에 Stack Navigation을 가장 최상단에 두기로 했다.

(1) StackNavigation.js

: Stack Navigation이다. 최상단에 위치해 있으며 상품 디테일, 후기작성, 후기리스트, 문의작성, 문의리스트 등등 모든 Stack Screen들이 위치해 있다.

// App.js
import StackNavigator from './navigation/StackNavigator';

export default App = () => {
  return (
    <NavigationContainer>
      <StatusBar barStyle='light-content' />
      <Provider store={store}>
        <StackNavigator /> // StackNavigation
      </Provider>
    </NavigationContainer>
  );
};
  • App.js에 StackNavigator만이 존재하는 것을 알 수 있다.
export default function StackNavigator() {
  // 중간 생략 StackNavigator 렌더 부분
  return (
    <Stack.Navigator
      screenOptions={{/*생략*/}}>
      <Stack.Screen
        name='main'
        component={BottomTabNavigation}
        options={/* 장바구니와 컨테이더 내용, 생략*/}/>
      <Stack.Screen
        name='productDetail'
        component={ProductDetail}
        options={{
          title: '제품상세',
          headerShown: false,
        }}
     /* 아래로 쭉 스택 스크린들이 나열된다... 이하 생략 ... */
      />
    </Stack.Navigator>
  );
  • StackNavigator.js 에는 이런식으로 스택으로 쌓을 스크린들이 아주 많이 모여있다
  • 첫번째 스크린인 홈 스택 이 이제 기본적으로 보여줄 페이지들의 묶음이다. 이 안에 BottomTab이 들어있다.
  • 홈 스택의 해더는 react-navigation 의 기본적인 헤더를 사용했다. 거의 마켓컬리와 동일하다.
  • 다만 나머지 스택들을 위에 쌓이는 용도로 형태가 기본형과 다르다. 그렇기 때문에 headerShown: false 을 사용해서 일단 헤더를 없애고 새로 만든다.
    (작명을 좀 실패했는데 Main이나 뭐 다른 이름을 써야했다, bottomTab 안에 홈이 또 있어서 헷갈린다)

(2) BottomTabNavigation.js

: 홈 스택의 컴포넌트 또한 네비게이션으로 바로 이 BottomTabNavigation이다.
이 네비게이션은 탭을 만들어주는 네비게이션으로 홈, 추천, 카테고리, 검색, 마이메뉴 를 만들어줄 것이다.

export default function BottomTabNavigation() {
  // 중간 생략 BottomTabNavigation 렌더 부분
  return (
    <Tab.Navigator
      initialRouteName={'홈'}
      tabBarOptions={/*생략*/}>
      <Tab.Screen
        name=''
        component={HomeTabNavigator}
        options={/*생략*/}
      />
 // ... 아래로 쭉 탭 스크린들이 나열된다... 생략 ...
    </Tab.Navigator>
  );
}
  • 홈 스택 스크린 안에 있는 탭스택이다. ~~이렇게 홈이라는 이름이 겹치는데...ㅠㅠ~~
  • 5개의 탭 중 첫번째 홈 탭에는 또 다시 5개의 메뉴가 있다.

(3) HomeTabNavigator.js

: 홈 탭의 컴포넌트 또한 또 한번 더 네비게이션이다. 바로 이 HomeTabNavigator 이다.

export default function HomeTabNavigator() {
    // 중간 생략 HomeTabNavigator 렌더 부분
  return (
    <>
      <MainTabStack.Navigator
        tabBarOptions={{
          activeTintColor: Theme.colors.mainColor,
          inactiveTintColor: Theme.colors.grayColor,
          pressColor: Theme.colors.mainColor,
          indicatorStyle: {
            borderBottomWidth: 3,
            borderBottomColor: Theme.colors.mainColor,
          },
          labelStyle: {
            fontSize: 13,
            fontWeight: '600',
          },
        }}>
        <MainTabStack.Screen name='컬리추천' component={HomeRecommend} />
        <MainTabStack.Screen name='신상품' component={NewProduct} />
        <MainTabStack.Screen name='베스트' component={BestProduct} />
        <MainTabStack.Screen name='알뜰쇼핑' component={Shopping} />
        <MainTabStack.Screen name='이벤트' component={Event} />
      </MainTabStack.Navigator>
    </>
  );
}
  • 위 코드가 메인 화면의 탭이다.
  • Navigarion은 이렇게 구조화 되어있다.

2) State

: 토글이나 오프셋 등의 State는 씬에서 개별적으로 사용되며 제품 정보와 같은 스택에서 사용되는 상태는 리덕스에서 관리된다.

// ProductDetail Redux 부분
export default setProductData = (state = productDetailData, action) => {
  if (action.type === 'setDetailData') {
    let copyState = { ...state };
    copyState = action.payload;
    return copyState;
  } else if (action.type === 'addReview') {
    let copyState = { ...state };
    let reviewState = [...state.review].concat(action.payload);
    copyState.review = reviewState;
    return copyState;
  }
};
  • 제품 상세 페이지 스택을 띄울때 리덕스가 사용된다. 클릭한 제품의 아이디에 맞는 값을 payload로 넘겨 어떤 곳에서 스택을 띄워도 해당 제품에 맞는 디테일 씬이 뜨도록 만들어 두었다.
  <FlatContainer
    onPress={() => {
      goToDetail();
      props.dispatch({
        type: 'setDetailData',
        payload: item,
      });
    }}
  >
  • Action 발생시 이런식으로 payload를 보내줄때는 dispatch 를 사용하여 보내준다.
const store = createStore(setProductData);

export default App = () => {
  return (
    <NavigationContainer>
      <StatusBar barStyle='light-content' />
      <Provider store={store}>
        <StackNavigator />
      </Provider>
    </NavigationContainer>
  );
};
  • 스토어가 Provider로 App 전역에 보내주고 있다.
function setRedux(state) {
  return {
    state: state,
  };
}

export default connect(setRedux)(/*blabla*/);
  • 데이터를 받을 씬에서는 connect로 연결해서 state로 넘겨받는다.

3) Scene

(1) Home Scene

: 가장 메인이 되는 화면이기 때문에 재밌는 기능이 많다.

(1) HomeRecommend.js

: 홍보용 배너 컴포넌트가 1개, 상품리스트 컴포넌트가 4개 있다.

  • 우측 하단에 보면 스크롤을 맨 위로 끌어올리는 기능이 있다.
// 맨 위로 올리기 기능 렌더 부분
onScrollEndDrag={showButton} // 스크롤뷰 속성
  • 스크롤뷰에 위와 같은 속성과 함수를 주어 이것으로 스크롤이 멈춘 시점에서의 y좌표를 뽑아낸다.
  const showButton = (e) => {
    if (e.nativeEvent.contentOffset.y - 0 > 400) {
      setToggle(true);
    } else {
      setToggle(false);
    }
  };
  • y축이 400px 보다 더 내려가면 버튼이 활성화가 되고 그 전에는 보이지 않게 되어있다.
  • 렌더부분에서 Opacity 를 삼항연산자로 주어 감춰두었다.
    (display:none과 둘 중 고민이 많았지만 추후 페이드 기능을 넣을 예정이라 투명도로 처리하기로 했다)
  const onUpPage = () => {
    scrollLocation.current.scrollTo({ y: 0 });
  };
  • 버튼을 클릭하면 내 스크롤은 가장 위로 올라가게 된다.

(2) HomeProductFlatList.js

//렌더 부분
  <FlatList
    data={props.data}
    onEndReached={props.onEndReached}
    keyExtractor={(product) => String(product.id)}
    onEndReachedThreshold={0.8}
    horizontal={true}
    showsHorizontalScrollIndicator={false}
    renderItem={renderItem}
    ListFooterComponent={
    props.loading ? <ActivityIndicator size='large' /> : null
    }
  />
  • onEndReachedThreshold 속성으로 무한 스크롤 기능을 만들 수 있는데 스크롤이 0.8로 설정해두어 80% 정도 내려갔을때 새로운 데이터를 불러온다.
  const getProductData = async () => {
    if (loading) {
      return;
    }
    setLoading(true);
    await fetch(
      'ProductAPI',
      { method: 'GET' }
    )
      .then((res) => res.json())
      .then((result) => {
        setProductData([
          ...productData,
          ...result.slice(offset, offset + LIMIT),
        ]);
        setOffset(offset + LIMIT);
      });
    setLoading(false);
  };
  • 애초에 통신할때 offset, LIMIT 으로 LIMIT 값을 설정해 두었기 때문에
const LIMIT = 4;
  • 이 리미트 값 갯수만큼의 데이터를 받아오게 된다.

(3) HomeRecipeFlatList.js

: HomeRecommend의 또 다른 컴포넌트로 레시피 공개하는 상품들의 리스트롤 보여준다. 위의 HomeProductFlatList와 거의 비슷하니 생략하고 타이머 기능면 소개해보자면...

  • 이런식으로 카운트다운이 시작된다.
import moment from 'moment';
  • 이 기능은 moment라는 라이브러리를 사용하였다.
  const [days, setDays] = useState(0);
  const [hours, setHours] = useState(0);
  const [mins, setMins] = useState(0);
  const [secs, setSecs] = useState(0);
  const [eventDate, setEventDate] = useState(
    moment.duration().add({ days: 3, hours: 13, minutes: 30, seconds: 60 })
  );
// useEffect 부분
  useEffect(() => {
    updateTimer();
  }, []);
// 타이머 함수
  const updateTimer = () => {
    const x = setInterval(() => {
      if (eventDate <= 0) {
        clearInterval(x);
      } else {
        setEventDate(eventDate.subtract(1, 's'));
        setDays(eventDate.days());
        setHours(eventDate.hours());
        setMins(eventDate.minutes());
        setSecs(eventDate.seconds());
      }
    }, 1000);
  };
  • useEffect를 통해 페이지 시작과 동시에 설정해둔 시간이 흐른다. (초기값을 new Data() 로 현재 시간을 기준으로 한다면 D-day기능이 되겠지?)

(4) NewProduct.js, BestProduct.js, frugalShopping.js

: 신상품, 베스트, 알뜰쇼핑은 전부 동일한 FlatList 컴포넌트를 재활용해서 API통신만 달리하고 있다.
(그러니 나머지는 생략)

  • 필터된 데이터도 서버에서 받아오고 있다. 프론트에서 State로 만드는 검색 기능은 실제 기업에서는 잘 사용하지는 않는다고 한다.
  • 아쉽게도 서버가 없고(개인적으로 만든 데이터 서버라) 주소가 제각각이라 템플릿 리터럴을 사용 못했다. 그냥 API 주소를 통채로 갈아끼웠다.
import RNPickerSelect from 'react-native-picker-select';
  • 셀렉트 박스는 react-native-pickerSelect 라이브러리를 사용했다.
const [sort, setSort] = useState('new');
// 렌더부분
<RNPickerSelect
  style={/*생략*/}
  placeholder={{
    label: '정렬필터',
    value: null,
  }}
  items={ITEM}
  onValueChange={(value) => {
    handleSelect(value);
    setSort(value);
  }}
  value={sort}
/>
  • RNPickerSelect 는 초기 정렬값을 state로 관리할 수 있다.
    (지금은 new로 되어있다)
const ITEM = [
  {
    label: '신상품순',
    value: 'new',
  },
  {
    label: '낮은가격순',
    value: 'low',
  },
  {
    label: '높은가격순',
    value: 'high',
  },
  {
    label: '추천순',
    value: 'recommend',
  },
];
  • 정렬 기준은 내가 만들어둔 배열객체 변수가 RNPickerSelectitems 에 적용된다.
  • item의 value 값을 사용해 필터를 다룰 수 있다.

스택 스크린들과 Bottom 메뉴들이 궁금하다면 2화에서 계속

profile
"꾸준한 삽질과 우연한 성공"

13개의 댓글

comment-user-thumbnail
2021년 1월 18일

진짜 엄청 잘하셨어요!! 항상 배우고 갑니다 ㅎㅎ

1개의 답글
comment-user-thumbnail
2021년 1월 18일

퀄리티가 대박이에요.. 기능별로 정리하신 부분 보고 자극받고 갑니다!!

1개의 답글
comment-user-thumbnail
2021년 1월 23일

레포 코드 보고 리네 공부하겠습니다 ^^7

1개의 답글
comment-user-thumbnail
2021년 1월 28일

배울게 많은 형이야 ~ 항상 고맙습니다.

1개의 답글
comment-user-thumbnail
2021년 1월 29일

프로필이 넘 겸손하시네요.. ㅎㅎ

1개의 답글
comment-user-thumbnail
2021년 2월 17일

상혁님 리네 알려주세요..🤭🤭

1개의 답글