Ilere: Expo와 Supabase를 활용한 투명한 렌탈 마켓플레이스 구축

발행: (2026년 4월 9일 AM 10:25 GMT+9)
13 분 소요
원문: Dev.to

I’m happy to translate the article for you, but I need the full text that you’d like translated. Could you please paste the content you want converted to Korean? Once I have the text, I’ll keep the source line unchanged and translate the rest while preserving all formatting, markdown, and technical terms.

Ilere – 모바일 주택 마켓플레이스

Ilere는 이바단, 라고스, 아부자, 포트하코트와 같은 나이지리아 도시를 대상으로 하는 모바일 주택 마켓플레이스입니다. 핵심 아이디어는 코드에서 확인할 수 있듯이, 세입자는 명확한 수수료 내역이 표시된 매물을 탐색하고, 매물을 열어 에이전트들의 경쟁 입찰을 받으며, 가장 좋은 제안을 선택하고 앱 내에서 대화를 이어갑니다. 실제 문제는 단순히 매물을 찾는 것이 아니라, 특히 에이전트 가격 책정 및 조정과 같은 임대 시장의 불투명한 부분을 비교할 수 있을 만큼 가시화하는 것입니다.

현재 코드베이스는 이 흐름을 넘어선 기능도 제공합니다. 세입자는 다음을 할 수 있습니다:

  • 주택 저장
  • 집주인 매물에 직접 연락
  • 인앱 및 푸시 알림 수신
  • 남용 사용자 신고 또는 차단

에이전트와 집주인은 매물을 게시할 수 있습니다. 그 결과, 프론트엔드와 데이터베이스 모두에 실제 워크플로우 경계가 내장된 꽤 의견이 반영된 모바일 제품이 탄생합니다.

아키텍처 개요

아키텍처는 좋은 의미에서 직관적입니다.

  • Shellsrc/app/_layout.tsx는 폰트를 로드하고, 공유 QueryClient를 보유하며, authService.getCurrentUser()를 통해 인증 상태를 초기화하고, 인증 변경을 구독하며, 알림 핸들러를 등록하고, 토스트 호스트를 마운트합니다.

  • Routingsrc/app 아래 Expo Router를 사용한 파일 기반 라우팅입니다. 라우트 그룹은 의미가 있습니다:

    • (auth) – 온보딩 및 로그인
    • (tabs) – 기본 앱 쉘
    • 상세 라우트 – house/[id].tsx, request/[id]/bids.tsx, chat/[id].tsx는 더 깊은 흐름을 위해 사용됩니다
  • State Management – 전역 클라이언트 상태는 거의 존재하지 않습니다.

    // src/features/auth/store.ts
    import { create } from "zustand";
    
    interface AuthState {
      user: User | null;
      isLoading: boolean;
      isResettingPassword: boolean;
      // …
    }
    
    export const useAuthStore = create((set) => ({
      user: null,
      isLoading: false,
      isResettingPassword: false,
      // …
    }));

    그 외 모든 것은 서버 상태로 간주되며 React Query에 존재합니다. 주택, 요청, 입찰, 채팅, 메시지, 알림, 저장된 주택은 모두 Supabase에서 생성되며 실시간 구독으로 인해 클라이언트 아래에서 자주 변경되므로 이를 클라이언트 스토어에 보관하면 불필요한 중복이 발생합니다.

  • API Layer – 화면은 기능 훅을 호출하고, 훅은 src/services/supabase/*에 있는 서비스 모듈을 호출하며, 서비스 모듈이 실제 Supabase 쿼리를 담당합니다.

    // src/services/supabase/client.ts
    import { createClient } from "@supabase/supabase-js";
    import AsyncStorage from "@react-native-async-storage/async-storage";
    
    export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
      auth: {
        storage: AsyncStorage,
        autoRefreshToken: true,
        persistSession: true,
        detectSessionInUrl: false,
      },
    });

    이 분리 구조는 대부분의 흥미로운 동작이 단순한 fetch가 아니기 때문에 효과적입니다. 서비스 레이어는 관계형 select를 많이 사용합니다. housesService.getHouses()house_photos와 게시 사용자를 한 번의 라운드 트립으로 조인하고, src/services/supabase/chat.ts는 참여자와 최신 메시지를 모두 로드한 뒤 클라이언트에서 last_messageunread_count를 파생합니다. Supabase는 순수 데이터베이스 클라이언트라기보다 백엔드 쿼리 인터페이스에 가깝게 동작합니다.

Source:

주목할 만한 구현 세부 사항

Auth State Change 우회

// src/services/supabase/auth.ts
return supabase.auth.onAuthStateChange((event, session) => {
  // …
  setTimeout(async () => {
    const { data, error } = await supabase
      .from("users")
      .select("*")
      .eq("id", userId)
      .single();
    // …
  }, 0);
});

콜백은 의도적으로 동기적으로 유지되고 setTimeout으로 후속 작업을 연기합니다. 이는 인증 상태 핸들러가 Supabase에 동기적으로 다시 호출될 때 발생하는 교착 상태 경고에 대한 실용적인 우회 방법입니다.

검색 정규화

// src/services/supabase/houses.ts
function sanitizeSearchTerm(input: string) {
  return (
    input
      .trim()
      .replace(/\s+/g, " ")
      .replace(/[^a-zA-Z0-9\s-]/g, " ")
      .replace(/\s+/g, " ")
      .trim() || undefined
  );
}

리스트 피드는 PostgREST .or(...) 필터를 만들기 전에 자유 텍스트 쿼리를 정규화합니다. 디바운싱(useDebounce(search, 350))과 src/features/houses/hooks/useHouses.tsuseInfiniteQuery와 결합하여 백엔드에 과도한 요청을 보내지 않으면서도 반응성을 유지합니다.

입찰 흐름

세입자가 입찰을 위해 리스트를 열 때(src/app/house/[id].tsx), 화면은:

  1. housesService.getFeeBreakdown()으로 수수료 내역을 로컬에서 계산합니다.
  2. 해당 리스트가 집주인 전용 직접 연락인지 확인합니다.
  3. useCreateRequest()를 사용해 요청을 엽니다.

뮤테이션은 requestsService.createRequest()(src/services/supabase/requests.ts)에 도달하며, 여기서는:

  • 가용성 확인
  • 입찰 흐름에 대한 집주인 리스트 거부
  • 중복 활성 요청 방지
  • 데이터베이스 고유 제약 조건이 발생하면 재조회하여 레이스 상황을 허용

낙찰 입찰 선택

// src/services/supabase/requests.ts
async function selectBid(requestId: string, bidId: string) {
  // 1. Update request status
  // 2. Mark chosen bid as accepted
  // 3. Fetch winning agent
  // 4. Create chat between tenant and agent
  await chatService.createChat(tenantId, bid.agent_id, requestId);
}

제품이 “마켓플레이스 협상”에서 “진행 중인 대화”로 전환되는 과정이 코드에 명시적으로 드러납니다.

채팅 – 실시간 및 낙관적 업데이트

// src/features/chat/hooks/useChat.ts
const optimistic: Message = {
  id: `optimistic-${Date.now()}`,
  chat_id: chatId,
  sender_id: user!.id,
  message: payload.message,
  message_type: payload.messageType,
  data: payload.data,
  read: false,
  created_at: new Date().toISOString(),
};

useSendMessage()는 낙관적인 메시지를 즉시 삽입하고, 성공 시 서버에서 확인된 메시지로 교체하며, 오류 시 이를 제거합니다. 이 조정 방식은 단순하면서도 효과적입니다.

요약

Ilere의 코드베이스는 관심사의 명확한 분리를 보여줍니다:

  • Routing & layout은 파일 기반이며 선언적입니다.
  • State는 최소한의 클라이언트‑사이드 Zustand 스토어(auth)와 서버‑사이드 React Query로 나뉩니다.
  • Service layer는 Supabase 쿼리를 추상화하여 관계형 선택, 실시간 구독, 비즈니스 로직(입찰, 채팅, 요청 생성)을 처리합니다.
  • Defensive patterns(인증 상태 타임아웃, 검색 정규화, 옵티미스틱 UI)는 앱을 반응형이고 견고하게 유지합니다.

전체적으로, 이 아키텍처는 제품의 핵심 문제와 잘 맞습니다: 나이지리아 세입자들에게 불투명한 임대 시장 가격과 조정을 가시화하고 비교 가능하게 만드는 것.

// Example snippet (original context was truncated)
false,
created_at: new Date().toISOString(),
};

전체‑screen Controls

Enter fullscreen mode
Exit fullscreen mode

Front‑end: src/app/chat/[id].tsx

  • message_type: "house_context"를 지원합니다.
  • 목록이나 선택된 요청에서 채팅을 시작할 때, 화면은 첫 번째 메시지에 구조화된 부동산 카드를 시드(seed)하여 양쪽 모두 자유 텍스트 회상에 의존하지 않고 공유 컨텍스트를 제공합니다.
  • 로직:
    • 페이로드를 정규화합니다.
    • 시드된 컨텍스트를 중복 제거합니다.
    • 메시지를 날짜별로 그룹화합니다.
    • 모더레이션 상태를 조정합니다.

백엔드: Supabase

기본 스키마 및 정책 – supabase/migrations/001_initial.sql

  • 테이블, 인덱스, RLS 정책, 트리거 및 실시간 퍼블리케이션을 정의합니다.
  • 신뢰 모델:
    • 사용자는 공개 엔티티(주택, 프로필)를 읽을 수 있습니다.
    • 쓰기auth.uid()에 의해 엄격히 제한됩니다.

채팅 중재 – supabase/migrations/033_chat_moderation.sql

  • user_blocks, user_reports를 추가합니다.
  • 트리거는 차단된 쌍에 대한 메시지 삽입을 거부합니다.

알림 흐름

  • 새로운 입찰 및 메시지는 트리거를 통해 notifications에 행을 생성합니다.
  • 이후 마이그레이션에서 추가:
    • 푸시 팬아웃.
    • 도시 기반 신규 매물 알림.
  • 엣지 함수 – supabase/functions/push-notify/index.ts:
    • 얇은 래퍼; 알림 시점을 Postgres가 결정합니다.
    • 함수는 페이로드를 Expo로 전달합니다.

Practical Performance & UX Details

  • Saved homessrc/features/houses/hooks/useSavedHomes.ts에서 낙관적 캐시 업데이트.

  • Media uploadssrc/services/supabase/storage.ts의 상수를 사용해 제한:

    export const MAX_PHOTO_SIZE_BYTES = …;
    export const MAX_VIDEO_SIZE_BYTES = …;
  • Notification displaynotificationService에서 라우트 인식; 사용자가 이미 /inbox 또는 /chat/...에 있을 때 포그라운드 알림을 억제.

  • Safe‑area handling & keyboard avoidance – 다음 전반에 걸쳐 일관되게 적용:

    • App shell
    • Tabs
    • Posting flow
    • Chat UI

리팩토링 생각

  • Stack – 재작성 필요 없음; Expo Router, React Query, Zustand(인증 전용), Supabase가 일관된 스택을 이룸.
  • Opportunities:
    • 쿼리‑키 규칙 통합 (현재 얇고 부분적으로 중복됨).
    • 비즈니스 규칙 중앙화 (현재 서비스, 화면, SQL 트리거에 흩어져 있어 불변성 감사를 단순화).
    • 스토리지 업로드 개선: 전체 파일을 Base64로 읽는 대신 스트리밍하여 메모리 사용량을 줄임.
    • 도구 체인 강화: expo lint가 자동으로 ESLint을 구성하고 네트워크 메타데이터를 가져오려다 보니 커밋된 프로젝트 설정을 사용하지 않아 결론을 내지 못함.

이러한 사항들은 이미 프로덕션‑레디 형태를 갖춘 앱에 대한 전형적인 다음 단계 개선 사항입니다.

0 조회
Back to Blog

관련 글

더 보기 »