한우의 개발일기

next프로젝트에 파이어베이스 express 적용기 본문

NextJS

next프로젝트에 파이어베이스 express 적용기

한우코딩 2025. 3. 7. 18:23

Firebase Firestore에서 서브컬렉션 구조로 데이터 모델링 개선하기

들어가며

이전 글에서 express로 서버를 따로 연후 파이어베이스와 연동하는 작업을 진행 하였습니다.

데이터베이스 설계는 애플리케이션의 성능과 확장성에 중요한 영향을 미친다는걸 알고는 있었지만 프론트엔드로써 직접 설계를 해본적은 한번도 없었다. 이번 작업 이후로 정말 데이터베이스 설계 또한 어플리케이션의 성능과 확장성에서 매우매우 중요하다는걸 느꼈다... ㅜ

일단 서브컬렉션이란?

Firebase Firestore에서 특별히 사용하는 개념입니다. 일반적인 데이터베이스 용어라기보다는 Firestore의 계층적 데이터 구조를 설명하기 위한 Firebase 전용 용어입니다. 일반 DB로 따지자면 아무래도
*계층적 데이터 구조(Hierarchical Data Structure) * 가 맞지 않을까 싶습니다

이번 포스트에서는 DevStudy Mate 프로젝트에서 Firebase Firestore의 데이터 구조를 studyNotes 컬렉션에서(단일 모델) users/{userId}/notes/{noteId} 서브컬렉션 구조(계층적 데이터 구조(Hierarchical Data Structure))로 변경한 경험을 공유하려고 합니다.

기존 데이터 구조의 한계

처음에는 단순함을 위해 다음과 같은 구조를 사용했습니다

studyNotes/
  - noteId1
    - userId: "user123"
    - title: "Note 1 Title"
    - ...other fields
  - noteId2
    - userId: "user456"
    - title: "Note 2 Title"
    - ...other fields

이 방식은 간단하고 직관적이었지만, 다음과 같은 한계가 있었습니다

  1. 필터링 필요: 사용자 노트를 조회할 때마다 where('userId', '==', userId) 필터링이 필요했습니다.
  2. 보안 규칙 복잡성: 각 노트에 대해 소유권을 확인하기 위한 보안 규칙이 복잡해졌습니다.
  3. 확장성 제한: 사용자 수가 늘어날수록 하나의 컬렉션에 모든 노트가 저장되어 성능에 영향을 줄 수 있었습니다.

서브컬렉션(계층적 데이터 구조)으로 전환

이러한 한계를 해결하기 위해 다음과 같은 서브컬렉션(계층적 데이터 구조)으로 전환했습니다

users/
  - userId1/
    - notes/
      - noteId1
        - title: "Note 1 Title"
        - ...other fields
      - noteId2
        - title: "Note 2 Title"
        - ...other fields
  - userId2/
    - notes/
      - noteId3
        - title: "Note 3 Title"
        - ...other fields

두 접근 방식 비교

1. 쿼리 효율성

기존 방식

const notesRef = collection(db, 'studyNotes');
const q = query(notesRef, where('userId', '==', userId));
const snapshot = await getDocs(q);

서브컬렉션 방식(계층적 데이터 구조)

const userNotesRef = collection(db, 'users', userId, 'notes');
const snapshot = await getDocs(userNotesRef);

서브컬렉션 방식(계층적 데이터 구조)은 필터링 과정이 없어 더 효율적이고 직관적입니다.

2. 보안 규칙

기존 방식

service cloud.firestore {
  match /databases/{database}/documents {
    match /studyNotes/{noteId} {
      allow read, write: if request.auth != null && request.auth.uid == resource.data.userId;
    }
  }
}

서브컬렉션 방식

service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      match /notes/{noteId} {
        allow read, write: if request.auth != null && request.auth.uid == userId;
      }
    }
  }
}

서브컬렉션 방식은 경로에 이미 사용자 ID가 포함되어 있어 보안 규칙이 더 간단해집니다.

3. 데이터 논리적 구조화

서브컬렉션 구조(계층적 데이터 구조)는 데이터의 논리적 소유권을 명확히 표현합니다. 사용자와 그들의 노트 간의 관계가 데이터 구조 자체에 표현되므로 직관적입니다.

4. 확장성

사용자가 증가해도 각 사용자의 데이터는 별도의 경로에 저장되므로, 단일 컬렉션의 크기 제한에 영향을 받지 않습니다.

마이그레이션 과정

서브컬렉션 구조로 전환하기 위해 다음과 같은 단계를 거쳤습니다:

  1. 서버 측 코드 수정: Firebase Admin SDK를 사용하여 새로운 경로에 접근하도록 변경
  2. 클라이언트 코드 수정: API 요청에 사용자 ID 포함하도록 수정
  3. 데이터 마이그레이션: 기존 데이터를 새 구조로 이전

코드 변경 예시

서버 측 (노트 생성 함수)

변경 전

async function createNote(note) {
  const docRef = await firestore.collection('studyNotes').add({
    ...note,
    createdAt: admin.firestore.FieldValue.serverTimestamp(),
    updatedAt: admin.firestore.FieldValue.serverTimestamp(),
  });
  return docRef.id;
}

변경 후

async function createNote(note) {
  const { userId, ...noteData } = note;
  const userNotesRef = firestore.collection('users').doc(userId).collection('notes');

  const docRef = await userNotesRef.add({
    ...noteData,
    createdAt: admin.firestore.FieldValue.serverTimestamp(),
    updatedAt: admin.firestore.FieldValue.serverTimestamp(),
  });
  return docRef.id;
}

클라이언트 측 (노트 조회 함수)

변경 전

export async function getNoteById(noteId: string): Promise<StudyNote | null> {
  try {
    const response = await fetch(`${API_URL}/api/note/${noteId}`, {
      method: 'GET',
    });
    // 처리 로직...
  } catch (error) {
    // 오류 처리...
  }
}

변경 후

export async function getNoteById(noteId: string, userId: string): Promise<StudyNote | null> {
  try {
    const response = await fetch(`${API_URL}/api/note/${noteId}?userId=${userId}`, {
      method: 'GET',
    });
    // 처리 로직...
  } catch (error) {
    // 오류 처리...
  }
}

결과 및 학습

서브컬렉션 구조(계층적 데이터 구조)로 변경한 후 얻은 주요 이점은 다음과 같습니다

  1. 쿼리 성능 향상: 사용자 노트 조회 시 필터링이 필요 없어 더 빠른 응답 시간
  2. 코드 가독성 향상: 데이터 구조가 논리적 관계를 반영하여 코드가 더 직관적으로 변경
  3. 보안 강화: 경로 기반 접근 제어로 보안 규칙이 간단해지고 실수할 가능성 감소
  4. 확장성 개선: 사용자별로 데이터가 분리되어 컬렉션 크기 제한에 영향을 덜 받음, 추후 사용자별 다른 데이터를 저장할때 새로운 필드 생성을 할 필요가 없어짐

고려 사항 및 주의점

서브컬렉션 구조(계층적 데이터 구조)로 바꾸면서 이게 일단은 최선이라 생각을 해서 변경하긴 했으나 항상 최선의 방법은 아닐거같아 여러 자료를 조사해보았스비다.
이런 상황에서는 기존 접근 방식이 더 적합할 수 있을거 같다라는 생각을 해봤습니다.

  1. 전체 데이터 조회가 필요한 경우: 모든 노트를 한 번에 조회해야 하는 경우, 서브컬렉션에서는 각 사용자를 순회해야 합니다.
  2. 공유 데이터: 여러 사용자가 동일한 데이터에 접근해야 하는 경우
  3. 단순한 구조의 소규모 애플리케이션: 사용자와 데이터가 적은 간단한 앱

결론

Firebase Firestore를 사용할 때 서브컬렉션 구조(계층적 데이터 구조)로의 전환은 "사용자가 자신의 데이터만 접근하는" 패턴에서 필요하다고 느껴서 마이그레이션을 진행을 하게 됐습니다(자기 자신의 학습노트 퀴즈 등등을 봐야하기 때문에). 사실 사용자가 엄청 많은 앱이 아닐거라 생각하여 그냥 갈까... 라는 생각도 들었지만 추후 판을 엄청 벌려놓고 고치는거보다 초기에 제대로 잡고가자 라는 생각에 마이그레이션을 진행하게 되었습니다.

데이터베이스 구조를 설계할 때는 현재의 요구 사항뿐만 아니라 미래의 확장성과 유지보수성도 고려해야 할거같다라는 생각이 들었습니다.
대충 설계하고 들어간다면 변경하게 될때 객체의 구조도 바뀌고 한다면 백엔드와 프론트엔드 모두 엄청난 고생을 하게 될거같다라는 생각이 들었습니다.

찐 결론

데이터베이스 구조는 한 번 정해지면 변경하기 어려울 수 있으므로, 초기 설계 단계에서 충분한 검토를 하고 들어가자