서브 쿼리로 조인 실행

개요

Firestore Enterprise 버전은 상관 서브 쿼리를 통해 관계형 스타일 조인을 지원합니다. 데이터를 비정규화하거나 여러 클라이언트 측 요청을 수행해야 하는 경우가 많은 NoSQL 데이터베이스와 달리 하위 쿼리를 사용하면 서버에서 직접 관련 컬렉션 또는 하위 컬렉션의 데이터를 결합하고 집계할 수 있습니다.

서브 쿼리는 외부 쿼리에서 처리하는 모든 문서에 대해 중첩된 파이프라인을 실행하는 표현식입니다. 이를 통해 관련 하위 컬렉션 항목과 함께 문서를 가져오거나 서로 다른 루트 컬렉션에서 논리적으로 연결된 데이터를 조인하는 등 복잡한 데이터 검색 패턴을 사용할 수 있습니다.

개념

이 섹션에서는 파이프라인 작업에서 조인을 수행하기 위해 하위 쿼리를 사용하는 핵심 개념을 소개합니다.

표현식으로서의 서브 쿼리

서브 쿼리는 최상위 단계가 아닙니다. 대신 select(...), add_fields(...), where(...), sort(...)와 같이 표현식을 허용하는 모든 단계에서 사용할 수 있는 표현식입니다.

Cloud Firestore는 세 가지 유형의 하위 쿼리를 지원합니다.

  • 배열 하위 쿼리: 하위 쿼리의 전체 결과 집합을 문서 배열로 구체화합니다.
  • 스칼라 하위 쿼리: 개수, 평균 또는 관련 문서의 특정 필드와 같은 단일 값으로 평가됩니다.
  • subcollection(...) 하위 쿼리: 일대다 상위-하위 관계에 대한 간소화된 조인입니다.

범위 및 변수

조인을 작성할 때 중첩된 하위 쿼리는 '외부' 문서 (상위 요소)의 필드를 참조해야 하는 경우가 많습니다. 이러한 범위를 연결하려면 let(...) 단계 (일부 SDK에서는 define(...)라고 함)를 사용하여 상위 범위에서 변수를 정의한 후 variable(...) 함수를 사용하여 하위 쿼리에서 참조할 수 있습니다.

문법

다음 섹션에서는 조인을 수행하는 구문을 간략하게 설명합니다.

let(...) 단계

let(...) 단계 (일부 SDK에서는 define(...)라고 함)는 상위 범위의 데이터를 명시적으로 이름이 지정된 변수로 가져와 후속 중첩 범위에서 사용하는 필터링되지 않는 단계입니다.

배열 서브 쿼리

배열 서브 쿼리는 서브 쿼리의 전체 결과 집합을 배열로 구체화하는 표현식 서브 쿼리의 특수한 사례입니다. 서브 쿼리가 0개의 행을 반환하면 빈 배열로 평가됩니다. null 배열을 반환하지 않습니다. 이러한 쿼리는 중첩되거나 상관관계가 있는 컬렉션을 구체화할 때와 같이 최종 결과에 전체 결과가 필요한 경우에 유용합니다.

쿼리는 하위 쿼리에서 필터링, 정렬, 집계를 수행하여 가져오고 반환해야 하는 데이터의 양을 줄여 쿼리 비용을 줄일 수 있습니다. 하위 쿼리의 순서가 적용됩니다. 즉, 하위 쿼리의 sort(...) 단계가 최종 배열의 결과 순서를 제어합니다.

toArrayExpression() SDK 래퍼를 사용하여 쿼리를 배열로 변환합니다.

스칼라 서브 쿼리

스칼라 서브 쿼리는 전체 쿼리를 직접 구체화하지 않고 서브 쿼리의 결과를 필터링하거나 결과로 반환할 수 있으므로 select(...) 또는 where(...) 단계에서 자주 사용됩니다.

결과가 0개인 스칼라 하위 쿼리는 null 자체로 평가되는 반면, 여러 요소로 평가되는 하위 쿼리는 런타임 오류를 발생시킵니다.

스칼라 하위 쿼리가 결과당 단일 필드만 생성하는 경우 필드가 하위 쿼리의 최상위 결과로 승격됩니다. 이는 서브쿼리가 select(field("user_name")) 또는 aggregate(countAll().as("total"))로 끝나고 서브쿼리의 스키마가 단일 필드인 경우에 가장 흔히 발생합니다. 그렇지 않으면 하위 쿼리가 여러 필드를 생성할 수 있는 경우 맵에 래핑됩니다.

toScalarExpression() SDK 래퍼를 사용하여 쿼리를 스칼라 표현식으로 변환합니다.

subcollection(...) 서브 쿼리

subcollection(...) 입력 단계는 단계로 제공되지만 Cloud Firestore의 계층적 데이터 모델에 대한 조인을 수행할 수 있습니다. 계층적 모델에서 쿼리는 자체 하위 컬렉션의 데이터와 함께 문서를 검색해야 하는 경우가 많습니다. collection_group(...) 입력 단계와 상위 참조에 대한 필터를 사용하여 이를 달성할 수 있지만 subcollection(...)는 훨씬 더 간결한 문법을 제공합니다.

암시적 조인 조건을 제외하고 이는 배열 하위 쿼리와 유사하게 작동하며, 중첩된 컬렉션이 없더라도 일치하는 문서가 없으면 빈 결과를 반환합니다.

기본적으로 구문적 편의입니다. 외부 범위에 있는 문서의 __name__를 조인 키로 자동으로 사용하여 계층적 관계를 해결합니다. 따라서 상위-하위 관계로 연결된 컬렉션에서 조회를 수행하는 데 선호되는 방법입니다.

예

예시 데이터

다음은 다음 모든 예시에서 사용할 테스트 데이터 집합을 로드합니다.

Node.js

// Load set of cities.
const cities = db.collection("cities");

await cities.doc("SF").set({
  name: "San Francisco",
  state: "CA",
  country: "USA",
});
await cities.doc("LA").set({
  name: "Los Angeles",
  state: "CA",
  country: "USA"
});
await cities.doc("DC").set({
  name: "Washington, D.C.",
  state: null,
  country: "USA"
});
await cities.doc("TOK").set({
  name: "Tokyo",
  state: null,
  country: "Japan"
});

// Load restaurants in various cities.
const sfRestaurants = db.collection("cities/SF/restaurants");
const laRestaurants = db.collection("cities/LA/restaurants");
const dcRestaurants = db.collection("cities/DC/restaurants");

const rest1 = await sfRestaurants.add({
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
});
const rest2 = await sfRestaurants.add({
  name: "Bay Area Burger",
  type: "burger",
  owner_id: "Sarah Jenkins"
});
const rest3 = await sfRestaurants.add({
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
});

const rest4 = await laRestaurants.add({
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
});
const rest5 = await laRestaurants.add({
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano"
});

const rest6 = await dcRestaurants.add({
  name: "Capitol Tacos",
  type: "mexican",
  owner_id: "Maria Garcia"
});
const rest7 = await dcRestaurants.add({
  name: "Georgetown Coffee",
  type: "cafe",
  owner_id: "David Kim"
});

// Load collection of reviews.
const reviews = db.collection("reviews");

await reviews.add({ restaurant: rest1, rating: 5, reviewer_id: "Alice" });
await reviews.add({ restaurant: rest1, rating: 4, reviewer_id: "Bob" });
await reviews.add({ restaurant: rest2, rating: 4, reviewer_id: "Charlie" });
await reviews.add({ restaurant: rest3, rating: 5, reviewer_id: "Diana" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Edward" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Fiona" });
// rest4 has 0 reviews
await reviews.add({ restaurant: rest5, rating: 3, reviewer_id: "George" });
await reviews.add({ restaurant: rest6, rating: 5, reviewer_id: "Hannah" });
await reviews.add({ restaurant: rest6, rating: 4, reviewer_id: "Ian" });
await reviews.add({ restaurant: rest7, rating: 5, reviewer_id: "Julia" });

다른 컬렉션에서 문서 조회

reviews 컬렉션 그룹에 대한 다음 쿼리는 기본 키 참조를 사용하여 restaurant 컬렉션 그룹을 조회합니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("reviews")
  .define(field("restaurant").as("restaurant_name"))
  .addFields(db.pipeline()
    .collectionGroup("restaurant")
    .where(field("__name__").equal(variable("restaurant_name")))
    .select("name", "type")
    .toScalarExpression()
    .as("restaurant"))
  .execute();

응답

{
  rating: 5,
  reviewer_id "Alice",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Bob",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Charlie",
  restaurant: { name: "Bay Area Burger", type: "burger" }
},
{
  rating: 5,
  reviewer_id "Diana",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Edward",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Fiona",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 3,
  reviewer_id "George",
  restaurant: { name: "Venice Pizza", type: "pizza" }
},
{
  rating: 5,
  reviewer_id "Hannah",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Ian",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 5,
  reviewer_id "Julia",
  restaurant: { name: "Georgetown Coffee", type: "cafe" }
}

여러 컬렉션 결합

다음 쿼리는 restaurants 컬렉션 그룹에서 모든 피자 가게를 가져오고 배열 하위 쿼리를 사용하여 관련 리뷰를 가져와 응답에 직접 삽입합니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("reviews"))
  .execute();

응답

{
  name: "Golden Gate Pizza",
  reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  reviews: [
    { rating: 3, reviewer_id "George" }
  ]
}

여러 컬렉션에서 집계

restaurants 컬렉션 그룹에 대한 다음 쿼리는 상관 서브 쿼리를 사용하여 reviews 컬렉션 그룹에서 각 레스토랑의 평균 평점을 가져옵니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .aggregate(average("rating").as("avg_rating"))
      .toScalarExpression()
      .as("avg_rating"))
  .execute();

응답

{
  name: "Golden Gate Pizza",
  avg_rating: 4.5
},
{
  name: "Venice Pizza",
  avg_rating: 3.0
}

그룹별 상위 N개 (제한이 있는 하위 쿼리)

다음 쿼리는 restaurants 컬렉션 그룹에서 모든 문서를 가져오고 상관 서브 쿼리를 사용하여 각 레스토랑의 평점이 가장 높은 상위 2개 리뷰를 가져옵니다.

이렇게 하면 리뷰 배열이 너무 커지지 않고 쿼리의 메모리 한도에 도달하지 않습니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .sort(field("rating").descending())
      .limit(2)
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("top_reviews"))
  .execute();

응답

{
  name: "Golden Gate Pizza",
  top_reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Bay Area Burger",
  top_reviews: [
    { rating: 4, reviewer_id "Charlie" }
  ]
},
{
  name: "Sunset Taco",
  top_reviews: [
    { rating: 5, reviewer_id "Diana" },
    { rating: 4, reviewer_id "Edward" }
  ]
},
{
  name: "Hollywood Sushi",
  top_reviews: []
},
{
  name: "Venice Pizza",
  top_reviews: [
    { rating: 3, reviewer_id "George" }
  ]
},
{
  name: "Capitol Tacos",
  top_reviews: [
    { rating: 5, reviewer_id "Hannah" },
    { rating: 4, reviewer_id "Ian" }
  ]
},
{
  name: "Georgetown Coffee",
  top_reviews: [
    { rating: 5, reviewer_id "Julia" }
  ]
}

하위 컬렉션 조인

다음 쿼리는 cities 컬렉션을 스캔하고 subcollection(...) 단계를 사용하여 중첩된 컬렉션의 문서를 암시적으로 조인하여 도시별 음식점 수를 찾습니다.

Node.js

let results = await db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count"))
  .execute();

응답

{
  __name__: cities/SF,
  name: "San Francisco",
  state: "CA",
  country: "USA",
  restaurant_count: 3
},
{
  __name__: cities/LA,
  name: "Los Angeles",
  state: "CA",
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/DC,
  name: "Washington, D.C.",
  state: null,
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/TOK,
  name: "Tokyo",
  state: null,
  country: "Japan",
  restaurant_count: 0
}

여러 조인 조건 표현

다음 쿼리는 restaurants 컬렉션 그룹을 스캔하고 reviews 컬렉션 그룹과 다중 필드 조인을 수행하여 자신의 음식점을 리뷰하는 소유자를 찾습니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .define(field("owner_id"), field("__name__"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("__name__")))
    .where(field("author").equal(variable("owner_id")))
    .aggregate(count().as("c"))
    .toScalarExpression()
    .greaterThan(0))
  .execute();

응답

{
  __name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
}

안티 조인 (NOT EXISTS)

다음 쿼리는 restaurants 컬렉션 그룹을 스캔하고 아직 리뷰가 없는 모든 레스토랑을 찾습니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("restaurant_name")))
    .aggregate(count().as("review_count"))
    .toScalarExpression()
    .equal(0))
  .execute();

응답

{
  __name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
}

조인으로서의 서브 쿼리

다음 쿼리는 각 피자 가게와 리뷰 간의 관계를 평면화합니다. 하위 쿼리를 unnest(...) 단계 내에 배치하면 서버는 일치하는 각 리뷰에 대해 외부 음식점 문서를 복제하여 평면화된 조인된 문서(SQL INNER JOIN와 유사)를 생성합니다.

Node.js

let results = await db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .unnest(
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("review"))
  .execute();

응답

{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
  review: { rating: 5, reviewer_id "Alice" }
},
{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi",
  review: { rating: 4, reviewer_id "Bob" }
},
{
  __name__: "cities/LA/restaurants/6CYntvNgbYzgaW652Gq1",
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  review: { rating: 3, reviewer_id "George" }
}

필터로서의 비상관 서브 쿼리

reviews 컬렉션에 대한 다음 쿼리는 자체적으로 상관관계가 없는 하위 쿼리를 사용하여 필터를 수행하여 평균 평점보다 높은 리뷰를 찾습니다.

Node.js

let results = await db.pipeline()
  .collection("reviews")
  // Average review rating is 4.3
  .where(field("rating").greaterThan(db.pipeline()
    .collection("reviews")
    .aggregate(average("rating").as("avg"))
    .toScalarExpression()))
  .select("rating", "reviewer_id")
  .execute();

응답

{
  rating: 5,
  reviewer_id "Alice"
},
{
  rating: 5,
  reviewer_id "Diana"
},
{
  rating: 5,
  reviewer_id "Hannah"
},
{
  rating: 5,
  reviewer_id "Julia"
}

권장사항

  • toArrayExpression()로 메모리 관리: toArrayExpression() 하위 쿼리를 사용할 때는 주의해야 합니다. 많은 수의 문서를 구체화하면 쿼리 메모리 한도 (128MiB)가 소진될 수 있기 때문입니다. 이를 완화하려면 하위 쿼리 내에서 select(...)를 사용하여 필요한 필드만 반환하고 where(...) 필터를 적용하여 반환되는 문서 수를 제한합니다. 하위 쿼리에서 반환되는 문서 수를 제한하려면 적절한 경우 limit(...)를 사용하는 것이 좋습니다.
  • 색인 생성: 하위 쿼리의 where(...) 절에 사용되는 필드가 색인화되었는지 확인합니다. 성능이 우수한 조인은 전체 테이블 스캔이 아닌 색인 검색을 수행하는 기능에 따라 달라집니다.

쿼리 권장사항에 대한 자세한 내용은 쿼리 최적화 가이드를 참조하세요.

제한사항

  • subcollection(...) 범위: subcollection(...) 입력 단계는 계층적 관계를 해결하고 조인을 수행하기 위해 상위 문서의 컨텍스트가 필요하므로 하위 쿼리 내에서만 지원됩니다.
  • 중첩 깊이: 하위 쿼리는 최대 20개 레이어까지 중첩될 수 있습니다.
  • 메모리 사용량: 구체화된 데이터에 대한 128MiB 제한은 조인된 모든 문서를 포함하여 전체 쿼리에 적용됩니다.