Skip to main content

Command Palette

Search for a command to run...

Firebase Firestore -> supabase Database 마이그레이션 경험기

Document형의 NoSQL에서 postgre SQL로.

Updated
5 min read
Firebase Firestore -> supabase Database 마이그레이션 경험기

2년차 프론트엔드 개발자입니다. 웹(React, Next.js)과 웹뷰, 앱(React Native) 개발 경험이 있어 플랫폼에 구애받는 개발이 가능합니다.

선 개발 후 개선에 따른 빠른 개발을 지향하고, 여러 번 QA와 테스트를 통해 기능을 개선합니다. 작업 경과를 중간, 완료 때마다 공유하고 논의해 소통 에러와 기능의 문제점을 최소화하고 있습니다.


계기는 언제나 사소한 것이었다.

개인 프로젝트에서 자주 사용하고 있던 BaaS인 Firebase에서 메일이 왔다.
2025년 10월 1일자로 Cloud Storage for Firebase가 유료화로 전환된다는 소식이었다.

지금은 Storage의 유료화에 대해서만 적혀있지만, 앞으로 다른 제품군에서도 무료 부문의 혜택이 줄어들거나 할 가능성이 있다고 판단이 됐다.
특히 DB인 Firestore나 Realtime DB의 무료 혜택이 줄어든다면… Firebase를 쓸 명분이 더 사라질 것이다.
그래서 결심했다.

이 기회에 Firebase에서 supabase로 이전해보자.

전 회사에서 일하면서 RDB 구조로 된 테이블들을 직접 보고 데이터를 검색, 수정한 적이 있었는데 거기서 느꼈던 장점도 있었고, 최근 많은 회사들에서 RDB를 채용 정보에 넣어놓은 경우도 보였다.

그런 부분에서 supabase는 데이터베이스를 Relational DataBase(관계형 데이터베이스)인 Postgre SQL로 형성되어 있어 언젠가의 마이그레이션 후보로 고민하고 있었기에 과감하게 시도해보기로 했다.

생각의 계기는 Storage의 유료화였지만, 실천의 결심은 결국 DB 구조를 바꾸기 위함이었다.

Firebase(NoSQL)에서 supabase(postgre SQL)로의 마이그레이션, 어떤 점이 좋았을까?

마이그레이션을 하면서 느낀 큰 장점을 뽑자면 세 가지 정도가 있었다.

  • 검색 기능 (쿼리문 처리)

    Firebase의 firestore에서 제공하는 쿼리 방식은 내가 원하는 데이터를 세밀하게 검색하는 게 쉽지 않다.

      const baseCollection = collection(await firestore(), `travels`, userUid, 'docs');
    
      let q;
    
      if (keyword) {
        q = query(
          baseCollection,
          where("title", ">=", keyword),
          where("title", "<=", keyword + "\uf8ff"),
          orderBy("departureAt", "asc")
        );
      } else {
        q = query(baseCollection, orderBy("departureAt", "asc"));
      }
    

    위의 코드에서 firestore 쿼리문의 한계점을 몇 가지 볼 수가 있다.

    1. 여러 컬렉션을 묶어서 한꺼번에 검색하는 게 어렵다.

    2. 문자열 검색 자체에도 제한 사항이 있어 단어 포함 검색 등을 처리하기가 어렵다.

그러나 supabase는 SQL을 이용한 postgreSQL 기반 데이터베이스이므로 위의 문제점을 해결할 수가 있다.

    let query = supabase
      .from("travels")
      .eq("user_uid", userUid)
      .order("departureAt", { ascending: true });

    if (keyword) {
      query = query.or(
        `title.ilike.%${keyword}%,destination.ilike.%${keyword}%`
      );
    }

이처럼 코드의 길이도 가독성있게 줄어들 뿐만 아니라, 좀 더 세부적인 단어 검색(ilike 등)을 통해 원하는 데이터를 검색하는 데에 큰 장점을 가진다.

  • 데이터 타입의 엄격성

    스키마는 DB에서 테이블이나 컬렉션 구조의 설계, 구조를 가리키는 단어이다.
    Firestore는 이런 스키마에 대해 내가 설계를 명확하게 했어도, 다른 구조의 데이터가 저장되거나 변형될 가능성이 있다.

    예를 들어 아래와 같은 User 타입이 있다고 하자.

      export interface User {
        id: string;
        email: string;
        nickname?: string;
        phone?: string;
        createdAt: Date;
      }
    

    이 형태의 타입에 대해서 Firestore로 코드를 작성할 경우, 아래의 케이스들이 다 허용된다.

      const db = getFirestore();
    
      await setDoc(doc(db, "users", "user1"), {
        email: "user1@example.com",
      });
    
      await setDoc(doc(db, "users", "user2"), {
        nickname: "로켓",
        phone: "010-1234-5678",
      });
    

    분명 우리는 User라는 타입에 required 값으로 email을 정의했는데, firebase에서는 스키마에 엄격하지 않기 때문에 email이 빠진 두 번째 형태도 데이터에 반영이 된다.
    그래서, 타입 단언 등을 하지 않으면 차후 데이터를 받아오거나 할 때 런타임 에러가 날 수 있다.

    이런 부분에서 supabase는 스키마가 테이블의 형태를 띠고 있어 구조에 대해서 엄격하다.
    조금이라도 어긋난 형태라면 저장 시 에러가 발생하거나, 불러올 때 에러가 발생할 수도 있다.

      const { data: user2, error: err2 } = await supabase
        .from("users")
        .insert([{ nickname: "로켓", phone: "010-1234-5678" }])
        .select()
        .single();
    
      if (err2) {
        console.error("user2 insert error:", err2.message);
      }
    

    위와 같이 코드를 작성해서 반영할 경우, email 데이터가 없다는 것을 깨닫고 에러가 발생한다.
    이처럼 SQL 기반일 경우, 타입이 잘못된 데이터를 수정하거나 저장할 위험으로부터 안전하게 데이터를 관리할 수 있다.

  • 트랜잭션 처리 방식
    firebase에서는 여러 상태 처리를 통합하여 하나의 단위로 관리하기 위한 트랜잭션의 처리를 JS 코드 내에서 해야했다.

      await runTransaction(await firestore(), async transaction => {
        await transaction.set(
          doc(collection(await firestore(), `elements`, userUid, 'docs'), id),
          data,
        );
    
        await transaction.set(
          doc(collection(await firestore(), `travels`, userUid, 'docs'), id),
          data.info,
        );
    
        await transaction.set(doc(await firestore(), `users`, userUid), {
          ...parseUserInfo,
          recentTravel: { title: data.info.title, id: data.info.id },
        });
      });
    

    이렇게 할 경우, 데이터베이스 내에서 특정 동작들을 처리하기 위한 로직들이 그대로 드러나있기 때문에 비즈니스 로직이 그대로 노출될 수 있다는 단점이 있다.

    하지만, supabase에서는 SQL문을 통해서 여러 여러 테이블의 테이블을 이용해 CRUD 처리하는게 다 가능하다.

      const { error: transactionError } = await supabase.rpc('post_elements_data', {
        userId,
        id,
        data: {
          ...data,
          info: {
            ...data,
            createdAt: dayjs().toISOString(),
            updatedAt: dayjs().toISOString(),
          },
        },
      });
    

    위처럼 JS 코드 내에서는 단지 어떤 트랜잭션 이벤트를 사용할지, 어떤 데이터를 전달할지만 노출시키면 되고 나머지 작업은 SQL 에디터를 통해 트랜잭션에서 처리할 이벤트들을 작성한다.

    SQL 에디터를 통해 supabase에서 처리할 트랜잭션 내부의 코드

    SQL문에 대해서는 사실 잘 모르기 때문에 이 점은 Claude 등을 활용해서 작업했었는데 구현 후 테스트를 해보니 비즈니스 코드가 외부에 공개되지 않는 점이 좋았고, SQL문 내에서 다른 테이블 간의 접근 자체가 용이한 점은 정말로 큰 장점이라고 느꼈다.

NoSQL에서 SQL로 옮기면서 고민했던 점?

사실 SQL로 전환할 때 제일 고민됐던 건 스키마 구조가 완전히 바뀐 것이므로 DB 구조 역시 전반적으로 변경할 수 밖에 없었다.
아래는 FireStore에서 사용하고 있던 한 컬렉션의 depth이다.

[Travels] -> Docs -> [user.id] -> Docs -> [travel.id] -> info -> {User}
                                                      -> elements -> Object[]...

위 구조에서 마지막 부분에 elements는 사실상 객체들의 배열로 관리되고 있었다.
당시 이렇게 했던 이유는 elements 안에 있는 각 객체들은 따로 분리해서 관리하기에는 너무나도 데이터가 많을 뿐더러, Firestore의 요금 정책은 패킷이 아닌 기능 호출 1회로 금액을 책정하는 방식이다 보니 많은 호출이 발생하지 않기를 바랐다.

이렇게 문서 형태의 firesotre의 NoSQL에서 supabase의 postgre SQL로 옮기자니, DB 구조 자체도 변경하는 것도 어려웠지만 저 elements에 대해 테이블을 만들어야할지에 대한 고민이 생겼다.

만약 테이블을 생성하게 된다면 element 객체 하나마다 id를 담은 형태로 elements의 테이블에서 관리해야 하고, 다음과 같은 문제점을 고민해야 했다.

  • 하나의 travel에 elements에 귀속된 데이터가 작게는 1개, 많아도 14개이다.
    travel이 생성될 때마다 elements의 데이터들이 늘어나게 될 텐데 양이 늘어나면 늘어날 수록 elements에서 소요될 검색 시간이 커져 불편함을 초래할 수 있다.

  • elements는 템플릿을 이용해서 생성되는 경우도 있기 때문에 elements를 연결하는 travels의 id만 다를 뿐이지 내용은 다 똑같아서 중복된 내용이 많을 수 있다.

이러다보니 elements의 테이블 구조를 어떻게 잡는 것이 좋을지, 그 전에 elements라는 테이블을 생성시키는 게 좋을지 고민이 되었고 이 사항에 대해서는 다른 백엔드 개발자들에게 자문을 구했고 그 결론은 JSON 형태 그대로 유지하는 방향으로 결정됐다.

  • 해당 데이터에는 해당 리스트를 사용하는 유저의 동작 등에 대해서 정보를 담고 있기 때문에, 지극히 개인적인 정보들이 포함되어 있다.

  • 이런 데이터를 검색할 게 아니라면 굳이 elements를 더 세분화시켜서 할 필요가 없고, 굳이 분리하여 데이터 찾기 위해 소모되는 시간을 늘릴 필요도 없다.

SQL이든 NoSQL이든 저마다의 장점이 있다

현재 프로젝트는 NoSQL로 한 80% 마이그레이션을 마친 거 같다.
아직 코드에서 테스트를 해보고 수정을 해야할 사항들이 있지만 그래도 어느 정도의 가닥은 좀 다져놨다고 생각했다.

이번 마이그레이션 과정을 통해서 SQL이 어떤 면에서 NoSQL 데이터베이스보다 좋은지에 대해서 살펴봤지만, 아까 난감했던 점처럼 SQL에서 처리하기에 고민이 되는 부분도 존재한다.

이런 면에서는 개인적으로 NoSQL로 문서화하여 처리하는 게 낫다고도 생각이 드는데, 이처럼 SQL이든 NoSQL이든 저마다의 장점들이 있다고 생각되기에 자신이 구현하고자 하는 프로젝트에서 어떤 DB 구조가 좋을지 고민해보고 구현하도록 하자.

まだね!

개발새발똥발

Part 2 of 6

개발 활동하면서 일단 쓰고 달리고 저지르고 했던 기록들을 남깁니다. Cover Photo By. @f12r in Unsplash

Up next

React Native WebView에서 폴더블 폰 너비를 대응해보자

useWindowDimensions 만으로는 해결할 수 없다.