-
[RN 강의] 게시글관련 기능 고도화하기강의노트/React Native 2025. 9. 20. 01:29
1. 더미데이터 세팅: miragejs
홈 화면에 게시글 목록을 더 추가하고, 무한 스크롤링을 구현해 본다. 현재 상황은 miragejs에 더미데이터들이 들어가 있기 때문에 앱이 재부팅 될 때마다 데이터들이 자꾸 바뀐다. 이렇게 데이터가 계속 바뀌면 테스트하기가 어렵다. 그래서 miragejs의 데미 데이터 관련된 설정을 추가적으로 더 알아보고 이용해 본다.
miragejs 더미 데이터 구조
- models: 데이터 정의와 함께 데이터의 관계를 설정
- serializsers: 데이터 형식을 정의
- seeds: 데이터 초기화 설정.
- factories: 데이터 생성.
- routes: 데이터 접근 정의.
주요 메서드
- schema.find: 특정 데이터를 찾음. schema.find("post", request.params.id);
- schema.all: 모든 데이터를 찾음. schema.all("post");
- schema.where: 특정 조건의 데이터 그룹을 찾음. schema.where("post", (v) => parseInt(v.id, 10) > 10);
- schema.destroy: 특정 데이터를 제거. schema.find("post", request.params.id)?.destroy();
- schema.destroy: 특정 데이터를 업데이트. schema.find("post", request.params.id)?.update("content", "update!!");
- schema.create: 데이터를 생성.
- create: 단일 객체를 생성
- createList: 복수 객체를 생성
게시글을 가져올 때는 DB의 모든 게시글이 아닌 화면에 보이는 범위의 데이터만 가져오도록 한다. 스크롤을 내리면 화면 끝에 도달할 즈음에 추가로 다시 앞서 가져온 만큼의 데이터를 더 가져오는 무한 로딩이 되도록 한다. miragejs에서 보내줄 데이터도 무한 스크롤을 구현할 때는 쿼리스트링을 읽어서 게시글을 가져오도록 구현한다.
⚠️ 주의할 점은 new Response 객체로 감싸주면 serializer가 동작하지 않기 때문에 models도 빼고 바로 넘겨주어야 한다.
this.get('/posts?cursor=10', (schema, request) => { console.log('user.all', schema.all('user').models); const cursor = parseInt((request.queryParams.cursur as string) || '0'); // const posts = schema.all('post').models.slice(cursor, cursor + 10); // // return new Response(200, {}, { posts }); // ❌ return schema.all('post').slice(cursor, cursor + 10); });Facker
더미데이터를 factory 부분에서 생성할 때 miragejs와 함께 쓰면 좋은 라이브러리로 faker-js가 있다. fakerjs는 랜덤값을 생성하는 라이브러리로 airline, database, image, person, string, lorem 등 다양한 세부 속성이 존재하여 편리하게 더미 데이터를 생성할 수 있다.
npm install @faker-js/faker --save-devfactories에서는 isVerified, likes, comments, reposts 처럼 user와 post에 정의되지 않았던 속성도 추가할 수 있다. 실수를 많이 하는 부분으로 화살표 함수의 함수 호출 부분(() =>)을 빼먹고도 사용하는 것이다. 함수 호출부분을 빼고 faker.person.firstName() 형태로도 사용할 수 있지만, 이런 경우 앱 실행 시 정해진 고정된 랜덤값이 들어가서 모든 가짜 데이터 값이 똑같아진다. 그렇기 때문에 함수를 쓸 때는 매번 함수가 호출돼서 매번 새로운 랜덤값을 사용하도록 함수 호출 부분을 넣어주어야 한다.
factories: { user: Factory.extend({ id: () => faker.person.firstName(), name: () => faker.person.fullName(), description: () => faker.lorem.sentence(), profileImageUrl: () => `https://avatars.githubusercontent.com/u/${Math.floor( Math.random() * 100_000 )}?v=4`, isVerified: () => Math.random() > 0.5, }), post: Factory.extend({ id: () => faker.string.numeric(6), content: () => faker.lorem.sentence(), imageUrls: () => Array.from({ length: Math.floor(Math.random() * 3) }, () => faker.image.urlLoremFlickr() ), // location: () => [faker.location.latitude(), faker.location.longitude()], likes: () => Math.floor(Math.random() * 100), comments: () => Math.floor(Math.random() * 100), reposts: () => Math.floor(Math.random() * 100), }), },Fixtures
miragejs에서 하나 더 소개할 만한 주제로 fixtures 기능이 있다. 더미 데이터를 만들어서 저장한다고 해도 메모리에만 저장되는 것이라 앱을 재부팅하면 다시 만들어야 한다. 그런데 만약 처음 생성한 데미 데이터로 계속 개발하고 싶다고 할 때 fixtures 기능을 사용하면 된다.
사용방법은 fixtures 폴더를 만들고 더미데이터를 파일로 저장하고, seeds에서 불러와서 사용할 수 있다. 이렇게 하면 매번 재생성 되는게 아니라 fixtures 파일에 있는 데이터로 고정되어 생성된다. 대신 fixtures의 모양은 models에 대응되서 같은 타입을 가져야 대응되서 더미 데이터로 들어간다.
// mirage/fixtures/countries.js export default [ { id: 1, name: "China", largestCity: "Shanghai" }, { id: 2, name: "India", largestCity: "Mumbai" }, { id: 3, name: "United States", largestCity: "New York City" }, { id: 4, name: "Indonesia", largestCity: "Jakarta" }, { id: 5, name: "Pakistan", largestCity: "Karachi" }, { id: 6, name: "Brazil", largestCity: "São Paulo" }, { id: 7, name: "Nigeria", largestCity: "Lagos" }, { id: 8, name: "Bangladesh", largestCity: "Dhaka" }, { id: 9, name: "Russia", largestCity: "Moscow" }, { id: 10, name: "Mexico", largestCity: "Mexico City" }, ] // index.ts import { createServer, Model } from "miragejs" import cities from "./fixtures/cities" import countries from "./fixtures/countries" import users from "./fixtures/users" createServer({ models: { country: Model, city: Model, user: Model, }, fixtures: { countries: countries, cities: cities, users: users, }, seeds(server) { // only load the countries and cities fixtures server.loadFixtures("countries", "cities") }, })이 밖에도 더미데이터를 활용하는데 편리한 기능이 많이 있으니, miragejs 공식 문서를 한번 읽어보는 것을 추천한다. 추천 방법은 튜토리얼을 그냥 쭉 읽어보고, API에서 원하는 기능을 찾아서 쓰면 된다. threads 클론 코딩에 사용된 내용들은 API 중에서도 Collection 파트 내용이 주로 사용되었다.
개별 게시글 데미 데이터 가져오기
특정 게시글의 id가 포함된 API 요청 endpoint를 받도록 구현해두었다. 댓글은 임의로 넣어두었는데, 백엔드였다면 정확하게 게시글과 댓글의 연결관계를 신경써야 겠지만 프론트 단에서 더미 데이터이기 때문에 신경쓰지 않았다. 프론트에서 활용하는 더미 데이터는 데이터가 전무하거나 데이터가 너무 많아서 계속 불러오는 큼직한 상황만 생각해두면 된다. 나중에 백엔드에서 알아서 주는 것이기 때문에, 더미 데이터를 너무 머리쓰며 DB 설계까지 고려할 필요는 없다.
this.post('/posts/:id', (schema, request) => { const post = schema.find('post', request.params.id); const comments = schema.all('post').models.slice(0, 10); return new Response(200, {}, { post, comments }); });2. FlashList로 성능 올리기: 무한 스크롤
For You 탭 게시글
기존의 home 탭의 하드 코딩된 게시물 데이터를 지우고, ScrollView 태그 대신 FlastList를 사용해서 무한 스크롤 기능 구현과 함께 리스트 성능을 향상 시켜본다. 앞서 리스트를 보여주는데 성능 최적화가 되어 있는 컴포넌트로 FlatList를 사용해 봤다. 그런데 FlatList보다 성능이 더 좋은 라이브러리가 존재하는데, Shopify에서 만든 FlashList이다.
FlashList는 간단하게 사용할 수 있으면서 성능까지 다 챙길 수 있는 라이브러리이다. FlatList 컴포넌트로 기능구현을 했다면, 단순히 FlashList로 바꿔주기만 하면 된다. 그러면 자동으로 성능상의 이점을 누릴 수 있다. Expo 53버전의 겨우 FlashList 1버전(1.7.x)이 설치될 것이다. 버전 1의 경우, estimatedItemSize나 estimatedListSize와 같이 예상되는 크기를 넣어주어야 그 수치를 바탕으로 FlashList가 최적화를 수행한다. 하지만 2번전의 경우 이런 부분이 사라지고 FlashList가 알아서 최적화를 수행한다.
npx expo install @shopify/flash-listFlashList를 사용한 무한 스크롤 코드는 아래와 같다. 홈 탭에는 For you와 Followers의 서브 주소가 있기 때문에 path를 가져와서 fetch에 적절하게 쿼리파라미터 type으로 넣어준다. 그리고 useEffect를 통해서 index 0 ~ 9 까지의 miragejs 더미 데이터 값을 렌더링한다.
// app/(tabs)/(home)/index.tsx export default function Index() { const colorScheme = useColorScheme(); const path = usePathname(); const [posts, setPosts] = useState<PostType[]>([]); useEffect(() => { console.log('path', path); setPosts([]); fetch(`/posts?type=${path.split('/').pop()}`) .then((res) => res.json()) .then((data) => { setPosts(data.posts); }); }, [path]); ...마지막 아이템의 onEndReachedThreshold 값에서 onEndReached 이벤트 핸들러가 동작한다. 이때 onEndReachedThreshold 값은 스크롤을 내려보며 이벤트 핸들러가 동작할 때 사용자 경험상 가장 적절한 값을 넣어주면 된다. 적절한 값이란 마지막 게시물의 화면 맨 끝에 도달하기 전에 다음 게시물이 원활하게 로딩되어 끊기지 않는 무한 스크롤 기능이 구현되는 임계값(threashold)이다. 앞서 화면 위로 넘어가버린 이전 게시물의 경우 알아서 FlashList가 최적화를 해주기 때문에 성능에 크게 걱정을 하지 않아도 된다.
estimateditemSize의 경우 설정을 안 해주면 터미널창에 경고 문구로 데이터에 따라서 특정 값으로 설정하길 권유하는데, 더미 데이터를 여러번 리로딩 하면서 터미널에 나오는 값의 평균 정도를 넣어서 FlashList가 최적화 되도록 해주면 된다. 게시글 데이터에 이미지가 없는 경우에 숫자가 작게 나온다. 그런데 FlashList 2번에서는 estimated 속성들이 없어지는데, 아직 알파 단계라 버그가 많아서 실무에서 쓸 건 아니라고 본다. 우선은 1버전을 쓰다가 안정화가 되면 2버전으로 넘어가길 권장한다.
참고로 keyExtractor는 FlashList에서는 사용하지 않는 속성이다.
... import { FlashList } from '@shopify/flash-list'; export default function Index() { ... return ( <View style={[ styles.container, colorScheme === 'dark' ? styles.containerDark : styles.containerLight, ]} > <FlashList data={posts} renderItem={({ item }) => <Post item={item} />} onEndReached={onEndReached} onEndReachedThreshold={2} estimatedItemSize={350} /> </View> ); }onEndReached 이벤트 핸들러는 게시글 데이터(posts)가 존재하는 경우에만 발생하며, 현재 마지막 게시글의 id를 쿼리파라미터 cursor 값으로 넣어서 이후 더미 데이터 10개를 요청한다. miragejs seeds에서 한 계정당 5개의 post를 생성하도록 했으므로, 50번째 마지막 게시글 이후부터는 빈배열이기 때문에 setPosts 호출되지 않아 데이터가 변하지 않고 무한 스크롤이 끝나게 된다.
const onEndReached = useCallback(() => { if (posts.length > 0) { console.log('onEndReached', posts.at(-1)?.id); fetch(`/posts?type=${path.split('/').pop()}&cursor=${posts.at(-1)?.id}`) .then((res) => res.json()) .then((data) => { if (data.posts.length > 0) { setPosts((prev) => [...prev, ...data.posts]); } }); } }, [posts, path]);// index.ts ... this.get('/posts', (schema, request) => { const posts = schema.all('post'); let targetIndex = -1; if (request.queryParams.cursor) { targetIndex = posts.models.findIndex( (v) => v.id === request.queryParams.cursor ); } return posts.slice(targetIndex + 1, targetIndex + 11); }); ...Following 탭 게시글
user0를 팔로잉 하고 있다고 가정을 하고, user0의 게시글만 가져오도록 Following 탭에서의 기능을 추가해본다. 우선 user0의 게시글을 만들어주기 위해서 user0를 생성하고, user0를 연결할 새로운 post 5개를 생성한다. 그리고 routes()에서는 쿼리파라미터 type이 'following'일 때, 즉 following tab에서는 팔로잉하고 있는 user0의 게시물만 가져오도록 한다.
// index.ts ... let user0: any; ... // 데이터 초기화 seeds(server) { user0 = server.create('user', { id: 'user0', name: 'User0', description: 'programmer, developer', profileImageUrl: 'https://avatars.githubusercontent.com/u/123456789?v=4', }); ... server.createList('post', 5, { user: user0 }); }, ... // 데이터 접근 정의 routes() { ... this.get('/posts', (schema, request) => { console.log('request', request.queryParams); let posts = schema.all('post'); if (request.queryParams.type === 'following') { posts = posts.filter((post) => post.user?.id === user0?.id); } let targetIndex = -1; if (request.queryParams.cursor) { targetIndex = posts.models.findIndex( (v) => v.id === request.queryParams.cursor ); } return posts.slice(targetIndex + 1, targetIndex + 11); }); ...For you 탭에서 Following 탭으로 스와프 제스쳐로 느리게 이동하면서, 두 탭의 게시물 데이터가 같은 이상 현상을 목격했다. 이 현상의 원인은 라이팅 경로인 pathname이 완전히 탭이 넘어가기 전에는 바뀌지 않기 때문이다.
코드상에서 보면 index.tsx에서 userEffect 에서 fetch를 수행할 때 path를 넣어주는데 탭이 완전히 바뀌어야 주소의 path가 변경된다. 즉, 라우팅 path가 완전히 바뀌지 않기 때문에 옆 탭의 데이터를 미리 로딩할 수 없는 것이다. 이 문제의 가장 간단한 해결책은 한 파일로 공유하던 탭 기능을 개별 following 파일을 하나 더 만들어서 구분을 해주어야 한다. 이제 파일로 탭별 코드가 분리되었으므로, 처음 로딩할 때 useEffect 코드가 필요없어진다.
// app/(tabs)/(home)/index.tsx ... // useEffect 제거 const onEndReached = useCallback(() => { console.log('onEndReached', posts.at(-1)?.id); fetch(`/posts?cursor=${posts.at(-1)?.id}`) .then((res) => res.json()) .then((data) => { if (data.posts.length > 0) { setPosts((prev) => [...prev, ...data.posts]); } }); }, [posts, path]); // app/(tabs)/(home)/index.tsx ... // useEffect 제거 const onEndReached = useCallback(() => { console.log('onEndReached', posts.at(-1)?.id); fetch(`/posts?type=following&cursor=${posts.at(-1)?.id}`) .then((res) => res.json()) .then((data) => { if (data.posts.length > 0) { setPosts((prev) => [...prev, ...data.posts]); } }); }, [posts, path]);현재 activity의 [tabs]나 [username]의 replies, reposts 탭 화면은 index.tsx 한 파일을 공유하고 있다. 탭을 공유하는 것은 장점도 있지만, home에서 처럼 단점도 발생한다. home에서는 pathname이 안 바뀌기 때문에 옆탭의 게시물을 미리 불러올 수 없는 문제가 발생한다. 어쩔 수 없이 중복 코드가 생기지만 home에서는 index와 following을 분리했다.
미리 데이터 불러오기: lazyPreloadDistance
다른 현상으로 following 탭으로 넘길 때 게시물이 없는 빈 화면이 보이고, 느리게 게시물이 렌더링 되는 현상을 볼 수 있다. 이는 앞서 설정한 MaterialTopTab에서 screenOptions의 laze 속성 때문이다. 이 laze 옵션의 동작이 활성화 되어 탭을 일정 부분 넘겨야지, 그때서야 다음 탭의 콘텐츠가 필요하다고 판단하고 로딩한다. laze 로딩 기능은 현재 화면에 필요한 콘텐츠만 가져오는 렌더링 최적화에 필수적인 기능이므로, laze 옵션을 유지하면서 일정 부분 미리 데이터를 불러오는 동작을 lazyPreloadDistance로 구현할 수 있다. lazyPreloadDistance 을 1로 설정해주면, 바로 옆 탭의 정도는 미리 로딩하도록 조정된다.
// app/(tabs)/(home)/_layout.tsx export default function TabLayout() { ... {isLoggedIn ? ( <MaterialTopTabs screenOptions={{ lazy: true, lazyPreloadDistance: 1, ...3. 개발자 도구 사용하기 (React Native DevTools)
React Native에서도 개발자 도구를 사용할 수 있다! 터미널에서 J를 누르면 React Native 개발자 도구가 열린다. 개발자 도구의 Components 탭은 CSS를 작성할 때 유용하고, 데이터 fetch와 관련해서는 Network 탭이 유용하다.


Expo 터미널 명령어 옵션(J): React Native DevTools 4. 진동 기능 추가
FlashList에서는 아래로 잡아당기는 제스쳐로 새로고침을 할 수 있는 기능을 제공한다. Threads에서는 밑으로 잡아당길 때부터 진동 기능을 켠다. 진동 기능을 활성화하는 라이브러리 Expo Haptics을 활용해보자. 코드에서는 Light 모드를 사용했지만 그 밖에 다른 강도(Heavy, Light, Medium, Rigid, Soft)도 제공한다.
npx expo install expo-haptics... import { FlashList } from '@shopify/flash-list'; import * as Haptics from 'expo-haptics'; export default function Index() { ... const [refreshing, setRefreshing] = useState(false); ... const onRefresh = () => { setRefreshing(true); setPosts([]); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); // 진동 활성화! fetch(`/posts`) .then((res) => res.json()) .then((data) => { setPosts(data.posts); }) .finally(() => setRefreshing(false)); }; return ( <View ... > <FlashList data={posts} renderItem={({ item }) => <Post item={item} />} onEndReached={onEndReached} onEndReachedThreshold={2} estimatedItemSize={350} refreshing={refreshing} onRefresh={onRefresh} /> </View> ); }5. 복잡한 새로고침 애니메이션 커스터마이징
애니메이션이 가능한 컴포넌트와 변수 선언
모바일은 아래로 잡아 당기면 제스쳐를 통해서 새로고침을 할 수 있다. 이번에는 기본으로 제공하는 React Native 새로침 컴포넌트(RefreshControl)를 사용하는 대신, 앞서 추가해본 진동과 스크롤 이벤트를 활용해서 새로고침이 가능한 컴포넌트로 대체할 것이다. 이를 통해 새로고침을 사용할 때 중간에 새로고침을 막을 수도 있고, 새로고침 중이라는 것을 보다 직관적으로 전달하여 더 나은 UX 제공할 수 있다.
기존의 RefreshControl porps에는 View 컴포넌트를 넣고, FlashList는 이제 애니메이션 가능한 컴포넌트로 바꿔준다. 그리고 스크롤 시에 onScroll 이벤트를 처리하는 scrollHandler 객체를 적용해준다. 여기서 사용되는 변수 scrollPosition는 UI 스레드를 사용하기 위해 useSharedValue 훅으로 선언한 React native Reanimated 변수이다. 이 변수는 useRef와 유사하게 자주 바뀌더라도 리렌더링 안 되는 값들을 저장한다. 그리고 UI 스레드란 네이티브 (Android, iOS)에서 화면을 그려주는 전용 스레드이다. 복잡한 애니메이션은 JavaScript에서 하는 것이 아니라 네이티브 단에 값을 보내서 처리를 한다. 그래서 useSharedValue 훅으로 선언한 값들은 JavaScript에서도 쓸 수 있으면서 네이티브 단이 같이 쓸 수 있게 만들어준다. 일반적으로 React Native 기본 Animated 쓸 때 Animated.Value를 쓰듯이, React Native Reanimated를 사용하며 변수 선언할 때는 useSharedValue를 쓴다고 생각하면 된다.
scrollEventThrottle props 설정을 통해 일정 주기로 스크롤 이벤트를 막음으로써 스크롤 강도를 조절할 수 있다. 16의 의미는 ms 단위가 아닌 scrollEventThrottle 속성의 단위값으로, 16 이하의 값을 넣어 스크롤 이벤트가 발생하는데로 막는 것 없이 계속 이벤트가 발생하도록 설정하 수 있다. 그래서 16으로 설정하면 최대한 이벤트가 발생되면서 부드러운 스크롤링을 구현할 수 있다.
import { FlashList } from '@shopify/flash-list'; import Animated, { useSharedValue } from 'react-native-reanimated'; const AnimatedFlashList = Animated.createAnimatedComponent(FlashList<PostType>); export default function Index() { ... // RN Animated 변수 선언 const scrollPosition = useSharedValue(0); // 자주 바뀌되 리렌더링 안됨 ... const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { console.log('onScroll', event.contentOffset.y); scrollPosition.value = event.contentOffset.y; }, }); return ( <Animated.View ... > <AnimatedFlashList data={posts} refreshControl={<View />} onScroll={scrollHandler} // 스크롤을 밑으로 내렸을 때 호출 scrollEventThrottle={16} // 최대한 부드럽게 refreshing={refreshing} renderItem={({ item }) => <Post item={item} />} onEndReached={onEndReached} onEndReachedThreshold={2} estimatedItemSize={350} /> </Animated.View> ); }Pan 제스쳐 핸들링
스크롤 이벤트은 감지할 수 있게 되었는데, 문제점이 하나 있다. onScroll 값은 마이너스일 수 없는 양수 값이라는 것이다. 그래서 위로 쓸어 올리는 동작과 달리, 밑으로 잡아당기는 것을 알아차릴 수가 없다. React Native에서는 밑으로 잡아당기는 제스쳐인, Pan을 알아차리기 위해서 PenResponder를 사용한다. onMoveShouldSetPanResponder는 무조건 Pan 작업에 반응하게끔 하고, 사용자가 스크롤 중에 onPanResponderMove를 통해서 gestureState.dy를 통해 y축 방향으로 변화량을 감지해 AnimatedFlashList를 감싸고 있는 View의 위치를 조작할 것이다.
Pan 제스쳐를 통해 pullDownPosition을 무제한으로 잡아당기지 않고, 최대값을 120으로 설정해둔다.
import { useRef, useContext } from 'react'; import { useSharedValue, PanResponder } from 'react-native'; ... export default function Index() { ... const isReadyToRefresh = useSharedValue(false); // 리프레시 준비 여부 const { pullDownPosition } = useContext(AnimatedContext); // _layout.tsx에서 선언한 변수 ... const panResponderRef = useRef( PanResponder.create({ onMoveShouldSetPanResponder: () => true, // Pan 작업에 반응하도록 설정 onPanResponderMove: (event, gestureState) => { console.log('onPanResponderMove', gestureState.dy); const max = 120; pullDownPosition.value = Math.max(Math.min(gestureState.dy, max), 0); console.log('pullDownPosition', pullDownPosition.value); if ( pullDownPosition.value >= max / 2 && isReadyToRefresh.value === false ) { isReadyToRefresh.value = true; } if ( pullDownPosition.value < max / 2 && isReadyToRefresh.value === true ) { isReadyToRefresh.value = false; } }, onPanResponderRelease: onPanRelease, onPanResponderTerminate: onPanRelease, }) ); ...onPanResponderRelease와 onPanResponderTerminate는 잡아당겼다가 놓는 순간에 사용된다. 잡았다가 놓는 순간에는 pullDownPosition을 0으로 천천히 변화하도록 효과를 넣어줄 수 있다. 이때 사용할 수 있는 것이 withTiming이다. withTiming의 두번째 인자값으로 들어가는 객체의 duration 만큼 천천히 첫번째 인자값으로 변한다.
import { withTiming } from 'react-native-reanimated'; ... const onPanRelease = () => { pullDownPosition.value = withTiming(isReadyToRefresh.value ? 60 : 0, { duration: 180, }); // 잡았다가 놨을 때 애니메이션 console.log('onPanRelease', isReadyToRefresh.value); if (isReadyToRefresh.value) { onRefresh(() => { pullDownPosition.value = withTiming(0, { duration: 180 }); }); } };AnimatedFlashList를 감싸고 있는 View 또한 애니메이션 가능한 컴포넌트로 만들어주고, panResponderRef를 적용해준다. 그리고 useAnimatedStyle 훅을 사용해 sharedValue 값을 바탕으로 스타일을 적용해줄 수 있다. 이렇게 해서 스크롤 할 때는 onScroll을 사용하고, 잡아당길 때는 panResponderRef와 pullDownStyles를 호출한다.
export default function Index() { ... const pullDownStyles = useAnimatedStyle(() => { return { transform: [{ translateY: pullDownPosition.value }], }; }); return ( <Animated.View style={[ styles.container, colorScheme === 'dark' ? styles.containerDark : styles.containerLight, pullDownStyles, // Pan 제스쳐로 아래로 내렸을 때 호출 ]} {...panResponderRef.current.panHandlers} > <AnimatedFlashList ... /> </Animated.View> ); }리프레싱 중에 돌아가는 로고 애니메이션 추가: layout 변수를 index로 공유하기
rotateStyle을 정의해서 잡아 당긴 만큼 돌아가는 애니메이션을 로고에 추가해주었다. 앞서 Pan 제스쳐 기능을 구현하면서도 사용한 pullDownPosition을 어떻게 index.tsx로 전달할 수 있을까? layout과 index 간에 부모자식 관계가 있기는 하지만 expo router가 관리하는 만큼 직접적으로 넘겨주기가 어렵다. 그래서 이럴 때는 contextAPI를 layout에서 정의하여 index로 값을 전달해주면 된다.
// app/tabs/(home)/_layout.tsx ... export const AnimatedContext = createContext<{ pullDownPosition: SharedValue<number>; }>({ pullDownPosition: null as any, }); export default function TabLayout() { ... const pullDownPosition = useSharedValue(0); // Native 단의 UI 스레드에서 처리 const rotateStyle = useAnimatedStyle(() => { return { transform: [{ rotate: `${pullDownPosition.value}deg` }], }; }); ... return ( <AnimatedContext.Provider value={{ pullDownPosition }}> <View ... <Animated.Image source={require('../../../assets/images/react-logo.png')} style={[styles.headerLogo]} /> ... </View> </AnimatedContext.Provider> ); }// app/(home)/(tebs)/index.tsx import { AnimatedContext } from './_layout'; const AnimatedFlashList = Animated.createAnimatedComponent(FlashList<PostType>); export default function Index() { ... const { pullDownPosition } = useContext(AnimatedContext); // _layout.tsx에서 선언한 변수 ...following 탭도 index(for you 탭)와 API 엔드포인트만 다를 뿐이지, 코드가 동일하므로 PanResponderRef와 로고 애니메이션을 똑같이 적용해준다.
정말 어려운 애니메이션 작업은 React Native Gesture Handler로, 손가락 여러 개로 터치해서 핀치 줌과 같은 복잡한 제스쳐 이벤트를 처리하는 부분이다. 앱은 인터랙션들이 많기 때문에 애니메이션들을 넣어주면 UX/UI에 도움이 많이 된다.
6. 게시글 상세 페이지 구현
우선 데이터를 추가하기 전에 게시물 상세 페이지에 하드 코딩되어 있는 데이터를 지우고, useEffect로 데이터를 불러와 데이터를 업데이트해주고, 게시물과 댓글 UI는 데이터가 있는 경우에만 보이도록 처리해 준다. 그리고 다른 게시글을 눌러 postID가 바뀔 때마다 useEffect가 다시 실행되며, 기존 데이터가 보여지는 것을 방지하기 위해 post와 comments를 초기화하도록 했다.
export default function PostScreen() { ... const { username, postID } = useLocalSearchParams(); const [post, setPost] = useState<PostType | null>(null); const [comments, setComments] = useState<PostType[]>([]); useEffect(() => { setPost(null); setComments([]); fetch(`/posts/${postID}`) .then((res) => res.json()) .then((data) => { console.log('post data', data); setPost(data.post); }); // 댓글 데이터 가져오기 fetch(`/posts/${postID}/comments`) .then((res) => res.json()) .then((data) => { setComments(data.posts); }); }, [postID]); return ( ... {post && ( <ScrollView style={styles.scrollView} nestedScrollEnabled={true}> ... </ScrollView> )} </View> ); } ...이제 게시물 상세 페이지를 위한 게시글과 댓글에 대한 get 요청 코드를 추가한다. serializer가 잘 되도록 return을 작성해주고, 댓글도 무한 스크롤링이 되도록 위한 corsor 값을 적용해준다.
// index.ts // 데이터 접근 정의 routes() { ... this.post('/posts/:id', (schema, request) => { // console.log('request', request.params.id); return schema.find('post', request.params.id); }); this.get('/posts/:id/comments', (schema, request) => { const comments = schema.all('post'); let targetIndex = -1; if (request.queryParams.cursor) { targetIndex = comments.models.findIndex( (v) => v.id === request.queryParams.cursor ); } return comments .sort((a, b) => parseInt(b.id) - parseInt(a.id)) .slice(targetIndex + 1, targetIndex + 11); // 최신순 정렬 }); ...7. 프로필 페이지 구현
이제 프로필을 누르면 해당 계정의 프로필 페이지가 나오도록 구현해본다. 하드 코딩된 샘플 데이터를 지우고, useEffect를 통해서 miragejs 더미 데이터를 API 요청을 통해 가져온다.
useEffect(() => { console.log('username', username, `@${user?.id}`); if (username !== `@${user?.id}`) { setProfile(null); fetch(`/users/${username}`) .then((res) => res.json()) .then((data) => { // console.log('fetch user', data); setProfile(data.user); }); } else { setProfile(user); } }, [username]);routers() 에 사용자 개인 페이지에 대한 API 요청도 처리해줄 수 있도록 추가해 준다. miragejs 데이터를 사용해서 만든 사용자의 아이디를 불러오면 id에 @가 붙어있어서 slice(1)을 해준다.
// index.ts // 데이터 접근 정의 routes() { ... this.get('/users/:id', (schema, request) => { return schema.find('user', request.params.id.slice(1)); // @Dovie -> Dovie }); ...알아두어야 할 것은 남의 프로필일 때는 프로필 탭의 아이콘이 활성화 되지 않는다. 오로지 내 프로필만 탭이 활성화 되고, 프로필 탭을 누르면 무조건 내 프로필로 이동하게 된다. 이 부분도 물론 커스터마이징을 해주어야 한다.
// app/(tabs)/_layout.tsx <Tabs.Screen name="[username]" listeners={{ tabPress: (e) => { if (!isLoggedIn) { e.preventDefault(); openLoginModal(); } else { router.navigate(`/@${user.id}`); } }, }} options={{ tabBarLabel: () => null, tabBarIcon: ({ focused }) => ( <Ionicons name="person-outline" size={24} color={ focused && user?.id === pathname?.slice(2) // '/@user0' ? colorScheme === 'dark' ? 'white' : 'black' : 'gray' } /> ), }} />다른 사람 프로필을 눌렀을 때, 내 프로필이 잠깐 보였다가 클릭한 프로필이 나오는 현상이 발생했다. 이 부분은 username 경로에서 fetch할 때 먼저 프로필 정보를 null로 만들어주고 데이터를 가져와서 렌더링하면 해결된다.
setProfile(null); fetch(`/users/${username}`) .then((res) => res.json()) .then((data) => { // console.log('fetch user', data); setProfile(data.user); });이제 프로필 탭에서 나의 게시물을 불러 올 수 있도록, 내부 탭인 Threads, replies, Reposts 탭 useEffect를 넣어준다. 그리고 이 useEffect는 username이 바뀔 때 마다 초기화를 수행하고 데이터를 불러온다.
// app/(tabs)/[username]/index.tsx ... useEffect(() => { setThreads([]); fetch(`/users/${username?.slice(1)}/threads`) .then((res) => res.json()) .then((data) => { setThreads(data.posts); }); }, [username]); ... // app/(tabs)/[username]/replies.tsx ... useEffect(() => { setReplies([]); fetch(`/users/${username?.slice(1)}/replies`) .then((res) => res.json()) .then((data) => { setThreads(data.posts); }); }, [username]); ... // app/(tabs)/[username]/reposts.tsx ... useEffect(() => { setReposts([]); fetch(`/users/${username?.slice(1)}/reposts`) .then((res) => res.json()) .then((data) => { setThreads(data.posts); }); }, [username]); ...8. 이미지 업로드 하기 (form-data)
프로필 페이지에서 FlashList의 Header 컴포넌트에 본인일 때만 'What's new?' 와 함께 Post 버튼이 보이도록 한다. Post 버튼 누르면 게시글 작성 화면(modal)으로 이동한다.
// app/(tabs)/[username]/index.tsx ... const Header = () => { const { user } = useContext(AuthContext); const colorScheme = useColorScheme(); const pathname = usePathname(); return pathname === '/@' + user?.id ? ( <View style={styles.postInputContainer}> <Image source={{ uri: user?.profileImageUrl }} style={styles.profileAvatar} /> <Text style={ colorScheme === 'dark' ? styles.postInputTextDark : styles.postInputTextLight } > What's new? </Text> <Pressable onPress={() => router.navigate('/model')} style={[ styles.postButton, colorScheme === 'dark' ? styles.postButtonDark : styles.postButtonLight, ]} > <Text style={[ styles.postButtonText, colorScheme === 'dark' ? styles.postButtonTextDark : styles.postButtonTextLight, ]} > Post </Text> </Pressable> </View> ) : null; }; export default function Index() { ... return ( <View style={[ styles.container, colorScheme === 'dark' ? styles.containerDark : styles.containerLight, ]} > <FlashList data={threads} ListHeaderComponent={<Header />} renderItem={({ item }) => <Post item={item} />} onEndReached={onEndReached} onEndReachedThreshold={2} estimatedItemSize={350} /> </View> ); } ...
프로필 탭 이제 어느 사용자이든지 프로필 탭에서 게시글을 작성했을 때, 작성이 되도록 routes() 부분을 개선해 본다. JSON.parse()를 사용하지 않고 FormData를 받도록 수정하고, miragejs에서 데이터를 받아 처리할 수 있도록 백엔드 로직을 추가해준다. 원래는 이런 백엔드 서버 로직을 신경쓸 필요는 없지만 최대한 백엔드와 동일한 모습을 구현해주기 위해 추가했. 이 백엔드 코드는 프론트에서 보낸 FormData를 백엔드에서 파싱해서 다시 객체 형태로 만들고, 이를 schema.create()로 miragejs에 다시 저장한다.
this.post('/posts', async (schema, request) => { const formData = request.requestBody as unknown as FormData; const posts: Record<string, string | string[]>[] = []; formData.forEach(async (value, key) => { const match = key.match(/posts\[(\d+)\]\[(\w+)\](\[(\d+)\])?$/); console.log('key', key, match, value); if (match) { const [_, index, field, , imageIndex] = match; const i = parseInt(index); const imgI = parseInt(imageIndex); if (!posts[i]) { posts[i] = {}; } if (field === 'imageUrls') { if (!posts[i].imageUrls) { posts[i].imageUrls = [] as string[]; } (posts[i].imageUrls as string[])[imgI] = ( value as unknown as { uri: string } ).uri; } else if (field === 'location') { posts[i].location = JSON.parse(value as string); } else { posts[i][field] = value as string; } } }); console.log('posts', posts); await new Promise((resolve) => setTimeout(resolve, 3000)); posts.forEach((post: any) => { schema.create('post', { id: post.id, content: post.content, imageUrls: post.imageUrls, location: post.location, user: schema.find('user', user0?.id), }); }); return posts; });Post 버튼을 눌렀을 때, routes()의 게시글 작성 API 요청의 형식(form)에 맞게 처리하는 핸들러는 아래와 같다. 게시글의 내용을 formData 형식으로 만들고, 이를 실제 서버 쪽으로 보내는 fetch 함수에서는 이미지 URI를 포함한 데이터를 application/json이 아니라 multipart/form-data 형식으로 보내는 것이 좋다. 이미지 URI는 주소 그래도 사용하면 안되고, 형식에 맞춰 객체 리터럴로 전달해야 한다. FormData는 원래 객체 형식을 받을 수 없기 때문에 location 쪽에서는 JSON.stringify()를 해주었지만, React Native에서만 특별하게 객체 형식을 알아서 파일 형태로 가져간다. fetch 함수가 정상적으로 완료되면, 새로 작성한 게시글의 주소로 이동한다.
const handlePost = () => { console.log('handlePost', threads); const formData = new FormData(); threads.forEach((thread, index) => { formData.append(`posts[${index}][id]`, thread.id); formData.append(`posts[${index}][content]`, thread.text); formData.append(`posts[${index}][userId]`, 'user0'); formData.append( `posts[${index}][location]`, JSON.stringify(thread.location) ); thread.imageUrls.forEach((imageUrl, imageIndex) => { formData.append(`posts[${index}][imageUrls][${imageIndex}]`, { uri: imageUrl, name: `image_${index}_${imageIndex}.png`, type: 'image/png', } as unknown as Blob); }); }); ... fetch('/posts', { method: 'POST', headers: { 'content-type': 'multipart/form-data', }, body: formData, }) .then((res) => res.json()) .then((data) => { console.log('post result', data); router.replace(`/@${data[0].userId}/posts/${data[0].id}}`); ... }) ... }); };9. 업로드 후 toast 메시지 띄우기
게시글을 작성하면 비동기 요청 후 데이터를 다시 불러오는 과정에서 실제 서버에서 처리하는 시간이 조금 걸리게 된다. 이와 유사하게 miragejs에서 게시글이 작성되는 것을 async를 통해서 3초 걸리도록 설정을 해준다. 게시글이 업로드되는 동작처럼 사용자가 기다려야 하는 작업에서는 사용자에게 피드백을 줘야 한다. 지금 기능이 제대로 동작하고 있는 건지 아닌지 알수가 없기 때문이다. 이럴 때 자주 사용되는 것이 Toast이다. Toast 메시지를 통해서 사용자가 게시글을 작성하고 난 뒤, 지금 게시글을 저장 중이라고 안내할 수 있도록 기능을 추가한다.
// indext.ts ... routes() { this.post('/posts', async (schema, request) => { ... await new Promise((resolve) => setTimeout(resolve, 3000)); ... return posts; });토스트 메시지를 띄우기 위해 많이 사용하는 React Native Toast 라이브러리를 설치한다.
npm i react-native-taost-messageToast 컴포넌트는 전역적으로 사용되므로 /app/_layout.tsx의 최상단에 넣어준다.
// /app/_layout.tsx ... import Toast from 'react-native-toast-message'; ... export default function RootLayout() { ... return ( <AnimatedAppLoader image={require('../assets/images/react-logo.png')}> <StatusBar style="auto" animated /> <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="(tabs)" /> <Stack.Screen name="modal" options={{ presentation: 'modal' }} /> </Stack> <Toast /> </AnimatedAppLoader> ); }Toast는 게시글 작성 요청을 보낼 때, 게시글 작성 modal에서 메시지가 5초 동안 보이도록 handlePost에 추가한다. 그런데 게시글이 서버에 5초 보다 더 빨리 작업이 완료될 수 있다. 그래서 기존 Toast를 요청 성공 또는 실패 시에 숨기고 다시 보여주는 식으로 처리해준다. Toast는 하단에서 20px 떨어진 곳에서 나타나도록 설정했다. type은 기본 Toast UI가 아닌, 이후에 정의할 customToast를 사용할 것이다.
// /app/modal.tsx ... const handlePost = () => { ... Toast.show({ text1: 'Posting...', type: 'customToast', visibilityTime: 5000, position: 'bottom', bottomOffset: 20, }); fetch('/posts', { method: 'POST', headers: { 'content-type': 'multipart/form-data', }, body: formData, }) .then((res) => res.json()) .then((data) => { console.log('post result', data); router.replace(`/@${data[0].userId}/post/${data[0].id}`); Toast.hide(); Toast.show({ text1: 'Post posted', type: 'customToast', visibilityTime: 5000, position: 'bottom', bottomOffset: 20, onPress: () => { console.log('post pressed', data); router.replace(`/@${data[0].userId}/posts/${data[0].id}}`); Toast.hide(); .catch((error) => { console.error('post error', error); Toast.hide(); }, }); }) .catch((error) => { console.error('post error', error); Toast.hide(); Toast.show({ text1: 'Post failed', type: 'customToast', visibilityTime: 5000, position: 'bottom', bottomOffset: 20, }); }); };Toast 커스터마이징
기본 Toast 스타일이 싫다면 Toast 컴포넌트에 config 설정을 통해서 커스터마이징을 해줄 수 있다. BaseToast 컴포넌트를 활용해서 추가해서 본인이 원하는 형태로 스타일링을 해주면 되는데, 아래 코드에서는 toastConfig의 키를 customToast로 정의했다. 이와 동일하게 기존에 있는 success, error 를 수정하고 싶다면 이곳에서 수정해주면 된다. Toast를 눌렀을 때 onPress 기능도 사용할 수 있도록 추가로 설정해준다.
// /app/_layout.tsx ... import Toast, { BaseToast } from 'react-native-toast-message'; ... export default function RootLayout() { const toastConfig = { customToast: (props: any) => ( <BaseToast style={{ backgroundColor: 'white', borderRadius: 20, height: 40, borderLeftWidth: 0, shadowOpacity: 0, justifyContent: 'center', }} contentContainerStyle={{ paddingHorizontal: 16, alignItems: 'center', height: 40, }} text1Style={{ color: 'black', fontSize: 14, fontWeight: '500', }} text1={props.text1} onPress={props.onPress} /> ), }; return ( <AnimatedAppLoader image={require('../assets/images/react-logo.png')}> <StatusBar style="auto" animated /> <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="(tabs)" /> <Stack.Screen name="modal" options={{ presentation: 'modal' }} /> </Stack> <Toast config={toastConfig} /> </AnimatedAppLoader> ); }

커스터마이징 Toast 결과물 .
.
.
.
.
.
.
threads-clone/index.ts at main · redcontroller/threads-clone
제로초 React native 학습 레포. Contribute to redcontroller/threads-clone development by creating an account on GitHub.
github.com
'강의노트 > React Native' 카테고리의 다른 글
[RN 강의] Expo 환경의 배포와 업데이트 (0) 2025.10.03 [RN 강의] Expo Go 그 이상의 기능들 (development builds) (0) 2025.09.29 [RN 강의] 편리한 Expo 라이브러리로 기능 구현 (0) 2025.09.08 [RN 강의] Mission: 게시글의 주제(topic) 선정 기능 (0) 2025.09.07 [RN 강의] 웹 개발과 유사한 Expo 앱 개발 (3) 2025.09.06