고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

React Native Tabs 구현하기

고윤태 2023. 11. 24. 15:06
안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 Tabs UI를 구현한 방식에 대해 공유하고자 글을 작성하게 되었습니다.

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

현재 진행 중인 프로젝트에서 아래 사진과 같은 Tabs 컴포넌트를 개발해야 했습니다.

사실상 React-Native의 TabView를 사용하면

(출처 : https://reactnavigation.org/docs/tab-view/)

 

손쉽게 개발이 가능했지만 직접 여러 개의 Tabs를 커스텀하면서 구현해보고 싶다는 욕구가 갑자기 불타올랐습니다.

이유는 아마도 제가 평소의 네이버 웹툰을 정말 좋아하는 편인데 사용하면서 나도 이런 UI, UX를 만들어 보고 싶다 생각을 종종 했었기에 기회가 와서 열정이 올라온 것 같습니다.
(쿠기 후원은 언제든 환영입니다)

 

(출처 : 네이버 웹툰)

🤔 기능 산출 및 개발 계획

개발적으로 봤을 때 Tabs는 각각의 탭의 Item을 Array로 받은 후 메뉴를 생성하고 Animation을 이용하여 Bottom의 Border를 이동시키는 것을 구현하는 것이었습니다.

 

UI적으로 봤을 때 제가 만들어야 하는 Tabs는 메뉴의 width가 전부 똑같은 비율을 가져야 하기에 flex : 1을 사용하여 동일한 width를 가지도록 했습니다.

 

(출처 : https://heewon26.tistory.com/275)

 

Flex 1 예제

 

사용한 코드

import React from 'react';
import { View } from 'react-native';

const Screen = () => {
  return (
    <View style={{ flexDirection: 'row', height: 100 }}>
      <View style={{ flex: 1, backgroundColor: 'red' }}></View>
      <View style={{ flex: 1, backgroundColor: 'yellow' }}></View>
      <View style={{ flex: 1, backgroundColor: 'blue' }}></View>
    </View>
  );
};

export default Screen;

 

구현된 화면

이렇게 동일한 width를 가진다면 border의 width는 전체 길이에서 Tab Menus의 개수만큼 나누면 원하는 값을 얻을 수 있습니다.

const width = Dimensions.get('window').width / menus.length;

 

이러한 코드로 간단하게 사용할 수 있습니다.


애니메이션을 위한 Left vs TranslateX

 

Bottom에 존재하는 Border의 슬라이딩 애니메이션을 구현해야 하는 단계에서 left와 translateX 중 어떤 방법을 선택해야 할지 제가 어떠한 고민을 거쳤고 어떠한 기준으로 선택했는지 작성하겠습니다.

 

Left를 사용하게 된다면 구현 난이도는 정말 간단했지만 useNativeDriver 옵션을 사용하지 못한다는 것과 이동 시마다 리플로우(React Native에서는 레이아웃 업데이트)가 발생합니다.

이러한 이유 때문에 저는 TranslateX를 사용하는 방법을 채택했습니다.

 

처음에는 아래 사진처럼 구구절절하게 useNativeDriver와 리플로우에 대해 작성했습니다만 제 목적은 쉽게 이해하도록 도와주는 것이 크다 생각하여 가볍게 적어보겠습니다.

 


useNativeDriver

 

useNativeDriver를 간단하게 설명하기 위해 Front-end는 JavaScript 스레드, Back-end 네이티브 스레드에 비유하겠습니다.

 

useNativeDriver 옵션이 true라면 애니메이션 계산은 네이티브 스레드에서 처리됩니다. 이것은 Front-end와 Back-end 개발자가 각각 자기가 개발해야 하는 파트에 집중하여 개발하는 것과 같습니다. 결과적으로 전체 서비스가 더 효율적이고 개발 속도가 빨라집니다.

useNativeDriver 옵션이 false라면 한 명의 개발자가 Front-end 영역과 Back-end 영역을 동시에 다 개발하는 것과 같습니다.

당연히 시간도 더 걸리고 효율성이 떨어집니다.

성능차이

useNativeDriver: true를 사용하면 애니메이션의 성능이 향상됩니다. 이는 네이티브 스레드에서 처리됨으로써 JavaScript 스레드의 부하가 줄어들고, 더 부드러운 애니메이션 효과를 얻을 수 있기 때문입니다. 마치 Front-end 개발자와 Back-end 개발자가 각자의 역할에 집중하여 전체 서비스의 질을 높이는 것과 비슷합니다.


리플로우

 

리플로우를 설명하기 위해 방으로 비유하여 설명하겠습니다.

 

(출처 : https://contents.ohou.se/cards/25275730?affect_type=CardSearch&affect_id=0&query=%EC%9B%90%EB%A3%B8%EA%BE%B8%EB%AF%B8%EA%B8%B0)

 

방에 여러 가지 가구들이 배치되어 있습니다. 이 가구들은 웹 페이지의 HTML 요소들에 해당하며, 방의 전체적인 배치는 웹 페이지의 레이아웃을 나타냅니다.

 

  1. 가구 배치 (Initial Layout): 처음에 방을 꾸밀 때, 모든 가구는 특정한 위치와 크기를 가지고 배치됩니다. 이는 웹 페이지가 처음 로드될 때 모든 HTML 요소가 렌더링 되는 것과 같습니다.
  2. 가구 크기 변경 (Reflow 발생): 이제 방에 있는 의자 하나의 크기를 변경한다고 상상해 봅시다. 이 의자의 크기를 변경하면, 방 안의 다른 가구들과의 관계도 재조정해야 할 수 있습니다. 예를 들어, 의자가 커지면 옆에 있던 테이블을 옮겨야 할 수도 있고, 통로가 좁아질 수도 있습니다. 이는 웹 페이지에서 하나의 요소의 크기나 위치가 변경될 때 발생하는 리플로우와 유사합니다. 한 요소의 변경이 전체 레이아웃에 영향을 미치기 때문입니다.
  3. 방의 재배치 (Reflow의 영향): 의자의 크기가 변경되면, 방 전체의 가구 배치를 다시 고려해야 합니다. 이 과정에서 다른 가구들도 영향을 받을 수 있으며, 최적의 공간 활용을 위해 전체적인 재배치가 필요할 수 있습니다. 이는 웹 페이지에서 리플로우가 발생했을 때, 브라우저가 전체 레이아웃을 다시 계산하고 렌더링 하는 과정과 같습니다.
  4. 효율성과 성능: 방의 가구를 자주 옮기면, 이는 시간과 노력이 많이 드는 일이 됩니다. 마찬가지로, 웹 페이지에서 불필요하게 자주 발생하는 리플로우는 성능 저하를 초래할 수 있습니다. 따라서, 레이아웃 변경을 최적화하고 필요한 경우에만 리플로우를 유발하는 것이 중요합니다.

👀 컴포넌트 개발(CODE)

Tabs.tsx

import { View, Pressable, Text, Animated, Dimensions } from 'react-native';
import Color from '../../constants/color';
import TYPOS from '../ui/typo';
import React from 'react';

interface Props {
  selectedIndex: number;
  onSelectHandler: (selectedIndex: number) => void;
  menus: string[];
}

const Tabs = ({ selectedIndex, onSelectHandler, menus }: Props) => {
  const width = Dimensions.get('window').width / menus.length;
  const animatedValue = React.useRef(
    new Animated.Value(selectedIndex * width)
  ).current;

  React.useEffect(() => {
    Animated.timing(animatedValue, {
      toValue: selectedIndex * width,
      duration: 250,
      useNativeDriver: true,
    }).start();
  }, [selectedIndex]);

  return (
    <View style={{ flexDirection: 'row', backgroundColor: Color.white }}>
      <Animated.View
        style={{
          position: 'absolute',
          left: 0,
          width: width,
          borderBottomWidth: 1,
          borderBottomColor: Color.primary700,
          transform: [{ translateX: animatedValue }],
          bottom: 0,
        }}
      />
      {menus.map((v, i) => (
        <Pressable
          style={{
            flex: 1,
            height: 44,
            alignItems: 'center',
            justifyContent: 'center',
          }}
          key={v}
          onPress={() => {
            onSelectHandler(i);
          }}
        >
          <Text
            style={[
              TYPOS.normal,
              {
                color: selectedIndex === i ? Color.primary700 : Color.neutral1,
              },
            ]}
          >
            {v}
          </Text>
        </Pressable>
      ))}
    </View>
  );
};

export default Tabs;

코드 설명

props

 

selectedIndex 현재 선택된 탭의 인덱스
onSelectHandler 탭이 선택될 때 호출되는 콜백 함수
menus 탭의 이름을 담고 있는 문자열 배열

 

Dimensions.get('window'). width를 사용하여 화면의 너비를 구하고, menus 배열의 길이로 나누어 각 탭의 너비를 계산합니다.

useEffect 훅을 사용하여 selectedIndex의 변화를 감지하고, Animated.timing을 사용하여 animatedValue를 변경합니다. 이를 통해 선택된 탭의 위치를 애니메이션으로 이동시킵니다.

 완성된 Tabs

사용한 예제 코드

import React, { useState } from 'react';
import { View } from 'react-native';
import Tabs from '../components/ui/Tabs';

const Screen = () => {
  const [tab, setTab] = useState(0);

  return (
    <View style={{ flex: 1 }}>
      <Tabs
        menus={['메뉴1', '메뉴2', '메뉴3']}
        onSelectHandler={(index) => {
          setTab(index);
        }}
        selectedIndex={tab}
      />
    </View>
  );
};

export default Screen;

Tabs

🫠 추가 작업

가볍게 완성된 Tabs 컴포넌트를 보여드렸지만 이대로 글을 끝낸 다면 제가 글 초반에 작성했던 네이버 웹툰 같은 UI를 구현한 상태는 아닙니다.

그러기에 이 Tabs를 응용해서 추가적인 개발을 더 진행한 것을 작성하도록 하겠습니다.

문득 다른 서비스에서는 Tabs를 어떻게 사용하는가? 궁금해졌습니다.

래퍼런스 수집

(출처 : 무신사 스토어)
(출처 : 배달의 민족)

 

현재 사진들의 UI를 보시면 제가 만들었던 Tabs와는 다르게 메뉴의 항목이 많다 보니 이러한 UI로 풀어낸 것을 볼 수 있습니다.

 

 

제 글을 읽으시는 분들 중에 당연히 저러한 UI의 Tabs를 구현하고 싶으신 분들도 계실 것이라 판단하여 저러한 UI의 Tabs도 구현해 보도록 하겠습니다.


개발

CODE

import React, { useRef, useState, useEffect } from 'react';
import { Pressable, Animated, LayoutChangeEvent, Text } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import Color from '../../constants/color';
import TYPOS from './typo';

interface Props {
  selectedIndex: number;
  onSelectHandler: (selectedIndex: number) => void;
  menus: string[];
}

interface TabLayout {
  x: number;
  width: number;
}

const Tabs = ({ selectedIndex, onSelectHandler, menus }: Props) => {
  const [tabLayouts, setTabLayouts] = useState<TabLayout[]>([]);
  const translateX = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    if (tabLayouts[selectedIndex]) {
      Animated.timing(translateX, {
        toValue: tabLayouts[selectedIndex].x,
        duration: 250,
        useNativeDriver: true,
      }).start();
    }
  }, [selectedIndex, tabLayouts]);

  const onTabLayout = (event: LayoutChangeEvent, index: number) => {
    const { x, width } = event.nativeEvent.layout;
    setTabLayouts((currentLayouts) => {
      const newLayouts = [...currentLayouts];
      newLayouts[index] = { x, width };
      return newLayouts;
    });
  };

  return (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      contentContainerStyle={{ paddingHorizontal: 16 }}
      style={{
        flexDirection: 'row',
        backgroundColor: Color.white,
        maxHeight: 32,
      }}
    >
      <Animated.View
        style={{
          position: 'absolute',
          transform: [{ translateX: translateX }],
          width: tabLayouts[selectedIndex]?.width ?? 0,
          borderBottomWidth: 1,
          borderBottomColor: Color.primary700,
          bottom: 0,
        }}
      />
      {menus.map((menu, index) => (
        <Pressable
          style={{
            height: 32,
            alignItems: 'center',
            justifyContent: 'center',
            paddingHorizontal: 16,
          }}
          key={menu}
          onLayout={(event) => onTabLayout(event, index)}
          onPress={() => onSelectHandler(index)}
        >
          <Text
            style={[
              TYPOS.normal,
              {
                color:
                  selectedIndex === index ? Color.primary700 : Color.neutral1,
              },
            ]}
          >
            {menu}
          </Text>
        </Pressable>
      ))}
    </ScrollView>
  );
};

export default Tabs;

 

완성된 Tabs

 

차이점

두 Tabs를 구현한 코드에서 차이점을 정리해 보겠습니다.

최상위 컴포넌트가 View로 구현이 되어 있는지와 ScrollView로 되어있는지 차이가 있습니다.

2번째 구현한 Tabs를 ScrollView로 구현한 이유는 배달의 민족과 같이 메뉴 항목들이 많다면 View를 사용하게 된다면 Scroll이 되지 않기에 ScrollView를 사용했습니다.

 

1번째 Tabs는 Dimensions.get('window'). width를 사용하여 탭의 너비를 계산합니다.

2번째 Tabs는 onTabLayout 함수를 사용하여 각 탭의 레이아웃 정보를 tabLayouts 배열에 관리합니다. 이 배열은 각 탭의 레이아웃 정보(x와 width)를 저장합니다.


한 번 더 레벨업 🔼

첫 번째로 만든 Tabs와 두 번째로 만든 Tabs 코드를 합친 후 props에 따라서 렌더링 되도록 하는 작업을 진행 보겠습니다.

 

Tabs.tsx

import React, { useRef, useState, useEffect } from 'react';
import {
  View,
  Pressable,
  Text,
  Animated,
  Dimensions,
  ScrollView,
  LayoutChangeEvent,
} from 'react-native';
import Color from '../../constants/color';
import TYPOS from './typo';

interface Props {
  selectedIndex: number;
  onSelectHandler: (selectedIndex: number) => void;
  menus: string[];
  type?: 'fixed' | 'dynamic';
}

interface TabLayout {
  x: number;
  width: number;
}

const Tabs = ({
  selectedIndex,
  onSelectHandler,
  menus,
  type = 'fixed',
}: Props) => {
  const [tabLayouts, setTabLayouts] = useState<TabLayout[]>([]);
  const animatedValue = useRef(new Animated.Value(0)).current;
  const windowWidth = Dimensions.get('window').width;
  const tabWidth =
    type === 'fixed'
      ? windowWidth / menus.length
      : tabLayouts[selectedIndex]?.width ?? 0;

  useEffect(() => {
    let toValue = 0;

    if (type === 'dynamic' && tabLayouts[selectedIndex]) {
      toValue = tabLayouts[selectedIndex].x;
    } else if (type === 'fixed') {
      toValue = selectedIndex * tabWidth;
    }

    Animated.timing(animatedValue, {
      toValue,
      duration: 250,
      useNativeDriver: true,
    }).start();
  }, [selectedIndex, tabLayouts, type, tabWidth]);

  const onTabLayout = (event: LayoutChangeEvent, index: number) => {
    if (type === 'dynamic') {
      const { x, width } = event.nativeEvent.layout;
      setTabLayouts((currentLayouts) => {
        const newLayouts = [...currentLayouts];
        newLayouts[index] = { x, width };
        return newLayouts;
      });
    }
  };

  const renderTabIndicator = () => (
    <Animated.View
      style={{
        position: 'absolute',
        transform: [{ translateX: animatedValue }],
        width: tabWidth,
        borderBottomWidth: 1,
        borderBottomColor: Color.primary700,
        bottom: 0,
      }}
    />
  );

  const renderTab = (menu: string, index: number) => (
    <Pressable
      style={{
        height: 32,
        alignItems: 'center',
        justifyContent: 'center',
        paddingHorizontal: 16,
        flex: type === 'fixed' ? 1 : undefined,
      }}
      key={menu}
      onLayout={(event) => onTabLayout(event, index)}
      onPress={() => onSelectHandler(index)}
    >
      <Text
        style={[
          TYPOS.normal,
          {
            color: selectedIndex === index ? Color.primary700 : Color.neutral1,
          },
        ]}
      >
        {menu}
      </Text>
    </Pressable>
  );

  return type === 'fixed' ? (
    <View style={{ flexDirection: 'row', backgroundColor: Color.white }}>
      {renderTabIndicator()}
      {menus.map(renderTab)}
    </View>
  ) : (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      contentContainerStyle={{ paddingHorizontal: 16 }}
      style={{
        flexDirection: 'row',
        backgroundColor: Color.white,
        maxHeight: 32,
      }}
    >
      {renderTabIndicator()}
      {menus.map(renderTab)}
    </ScrollView>
  );
};

export default Tabs;

 

사용 예시

import React, { useState } from 'react';
import { View } from 'react-native';
import Tabs from '../components/ui/Tabs';

const Screen = () => {
  const [tab, setTab] = useState(0);
  const [tab1, setTab1] = useState(0);

  return (
    <View style={{ flex: 1 }}>
      <Tabs
        menus={['메뉴1', '메뉴2', '메뉴3']}
        onSelectHandler={(index) => {
          setTab(index);
        }}
        type='fixed'
        selectedIndex={tab}
      />
      <Tabs
        menus={['메뉴1', '메뉴2', '메뉴3']}
        onSelectHandler={(index) => {
          setTab1(index);
        }}
        type='dynamic'
        selectedIndex={tab1}
      />
    </View>
  );
};

export default Screen;

 

구현된 UI

 


또 한 번 더 레벨업 

사용한 코드

import React, { useState, useMemo } from 'react';
import { View } from 'react-native';
import Tabs from '../components/ui/Tabs';
import Child1 from '../components/Example/Child1';
import Child2 from '../components/Example/Child2';
import Child3 from '../components/Example/Child3';

const Screen = () => {
  const [tab, setTab] = useState(0);

  const content = useMemo(() => {
    switch (tab) {
      case 0:
        return <Child1 />;
      case 1:
        return <Child2 />;
      case 2:
        return <Child3 />;
      default:
        return null;
    }
  }, [tab]);

  return (
    <View style={{ flex: 1 }}>
      <Tabs
        menus={['메뉴1', '메뉴2', '메뉴3']}
        onSelectHandler={(index) => {
          setTab(index);
        }}
        type='fixed'
        selectedIndex={tab}
      />
      {content}
    </View>
  );
};

export default Screen;

 

위의 코드를 사용하여 화면을 구성했습니다.

선택된 탭에 따라서 Child 컴포넌트를 렌더링 하는 흔한 방식이었습니다.

 

구현된 UI

 

 

사용자에게 제공되는 화면을 보면 확실히 아쉽다는 느낌이 강합니다.

그 이유가 뭘까요?

 

바로 위에서 작성했듯이 저는 네이버 웹툰 UI, UX를 이미 눈에 담고 온 상황에 Tabs의 애니메이션은 동일하지만 스크린이 전환이 되는 애니메이션이 없기에 제 눈을 만족하고 있지 않는 것입니다.

그렇다면 동일하게 만들기 위해 저는 어떤 식으로 구현을 할까 고민에 빠졌습니다.

연관성(?)은 잘 모르겠지만 제 머릿속에 떠오른 힌트가 하나 있었습니다.

 

바로 중국식 원형 테이블이었습니다.

 

 

이 말은 무엇이냐 자기가 먹고 싶은 것을 테이블을 회전하여 자기의 앞에 배치하는 방식입니다.

 

 

그러한 구조처럼 Child를 가로 정렬해 놓고 사용자가 선택한 Tab에 맞는 Child를 넘기면서 볼 수 있도록 구현하면 된다 생각했습니다.

이 아이디어를 바탕으로 코드 구현 기반에 도움을 준 것은 carousel이었습니다. 사실상 원리가 비슷하다고 생각하고 접근을 했습니다.

저의 생각을 구현한 코드를 보여드리겠습니다.


완성한 코드

import { useState, useRef } from 'react';
import { Dimensions, View, ScrollView } from 'react-native';
import Tabs from './Tabs';

interface Props {
  menus: string[];
  initTabIndex?: number;
  contents: React.ReactNode[];
}

const TabScreen = ({ menus, initTabIndex = 0, contents }: Props) => {
  const [selectedIndex, setSelectedIndex] = useState(initTabIndex);
  const scrollViewRef = useRef<ScrollView | null>(null);
  const windowWidth = Dimensions.get('window').width;

  const onSelectTab = (index: number) => {
    setSelectedIndex(index);
    scrollViewRef.current?.scrollTo({
      x: windowWidth * index,
      animated: true,
    });
  };

  return (
    <View style={{ flex: 1 }}>
      <Tabs
        menus={menus}
        selectedIndex={selectedIndex}
        onSelectHandler={onSelectTab}
      />
      <ScrollView
        horizontal
        pagingEnabled
        ref={scrollViewRef}
        scrollEventThrottle={16}
        showsHorizontalScrollIndicator={false}
        onMomentumScrollEnd={(e) => {
          const newIndex = Math.floor(
            Math.max(0, e.nativeEvent.contentOffset.x) / windowWidth
          );
          setSelectedIndex(newIndex);
        }}
      >
        {contents.map((content, index) => (
          <View
            key={index}
            style={{
              width: windowWidth,
            }}
          >
            {content}
          </View>
        ))}
      </ScrollView>
    </View>
  );
};

export default TabScreen;

 

코드 설명

 

초기 탭 인덱스(initTabIndex)가 주어지면, 해당 탭이 처음에 선택된 상태로 설정됩니다.

 

사용자가 Tabs 컴포넌트의 다른 탭을 선택하면, onSelectTab 함수가 호출되어 ScrollView가 해당 탭의 콘텐츠 위치로 자동으로 스크롤됩니다.

 

ScrollView 내에서 사용자가 수평으로 스와이프 하여 콘텐츠를 변경하면, onMomentumScrollEnd 이벤트가 발생하여 현재 보이는 콘텐츠에 맞는 탭이 선택됩니다.

 

const newIndex = Math.floor(Math.max(0, e.nativeEvent.contentOffset.x) / windowWidth);

 

이 코드에서 Math.max를 사용한 이유는 스크롤 위치 값(e.nativeEvent.contentOffset.x)이 음수가 되는 경우를 처리하기 위함입니다.

스크롤 위치가 음수인 경우: 사용자가 ScrollView의 시작 부분을 넘어서 스크롤하려고 할 때, contentOffset.x는 음수 값을 가질 수 있습니다. 이 경우 Math.max 함수는 0을 반환하여, 음수 스크롤 위치를 0으로 재설정합니다. 이렇게 하면 스크롤 위치가 잘못 계산되는 것을 방지할 수 있습니다.


구현된 UI

예제 코드

import { View, Text } from 'react-native';
import TabScreen from '../components/ui/TabScreen';

const Screen = () => {
  return (
    <TabScreen
      menus={['메뉴1', '메뉴2', '메뉴3']}
      contents={[
        <View style={{ backgroundColor: '#C6DBDA', flex: 1 }}>
          <Text>Content1</Text>
        </View>,
        <View style={{ backgroundColor: '#FED7C3', flex: 1 }}>
          <Text>Content2</Text>
        </View>,
        <View style={{ backgroundColor: '#ECD5E3', flex: 1 }}>
          <Text>Content3</Text>
        </View>,
      ]}
    ></TabScreen>
  );
};

export default Screen;

😁 글 작성 후기

전부터 너무나 해보고 싶었던 개발이기에 작업 시작부터 끝까지 또 블로그 글을 작성할 때까지 너무나도 재밌는 작업이었습니다.

그래서 더더욱 코드적으로도 UI, UX적으로도 글에서도 퀄리티를 챙기려고 많은 노력을 한 거 같습니다.

저 스스로 완성했다는 생각은 최대한 뒤로 미루고 놓친 것은 더 없는지 확인하며 진행했습니다. 

 

 

이번에 진행하는 프로젝트에서는 라이브러리 사용보다는 직접 개발을 하는 것을 목표로 잡고 시작한 프로젝트이기 때문에 더 배우는 것이 많은 것 같습니다.

라이브러리의 코드를 뜯어보거나 이 사람은 이걸 어떻게 구상해서 만들었을까 하면서 동작을 유심히 보기도 하며 제 생각과 합쳐 컴포넌트로 만드는 작업에서 뿌듯함도 많이 생기고 얻는 것도 많은 프로젝트를 하고 있다고 생각합니다.

 

항상 읽어주셔서 감사합니다. 피드백은 언제든지 환영입니다!!!