개요
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 = collection(db, "cities");
await setDoc(doc(cities, "SF"), {
name: "San Francisco",
state: "CA",
country: "USA",
});
await setDoc(doc(cities, "LA"), {
name: "Los Angeles",
state: "CA",
country: "USA"
});
await setDoc(doc(cities, "DC"), {
name: "Washington, D.C.",
state: null,
country: "USA"
});
await setDoc(doc(cities, "TOK"), {
name: "Tokyo",
state: null,
country: "Japan"
});
// Load restaurants in various cities.
const sfRestaurants = collection(db, "cities", "SF", "restaurants");
const laRestaurants = collection(db, "cities", "LA", "restaurants");
const dcRestaurants = collection(db, "cities", "DC", "restaurants");
const rest1 = await addDoc(sfRestaurants, {
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi"
});
const rest2 = await addDoc(sfRestaurants, {
name: "Bay Area Burger",
type: "burger",
owner_id: "Sarah Jenkins"
});
const rest3 = await addDoc(sfRestaurants, {
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
});
const rest4 = await addDoc(laRestaurants, {
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
});
const rest5 = await addDoc(laRestaurants, {
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano"
});
const rest6 = await addDoc(dcRestaurants, {
name: "Capitol Tacos",
type: "mexican",
owner_id: "Maria Garcia"
});
const rest7 = await addDoc(dcRestaurants, {
name: "Georgetown Coffee",
type: "cafe",
owner_id: "David Kim"
});
// Load collection of reviews.
const reviews = collection(db, "reviews");
await addDoc(reviews, { restaurant: rest1, rating: 5, reviewer_id "Alice" });
await addDoc(reviews, { restaurant: rest1, rating: 4, reviewer_id "Bob" });
await addDoc(reviews, { restaurant: rest2, rating: 4, reviewer_id "Charlie" });
await addDoc(reviews, { restaurant: rest3, rating: 5, reviewer_id "Diana" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Edward" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Fiona" });
// rest4 has 0 reviews
await addDoc(reviews, { restaurant: rest5, rating: 3, reviewer_id "George" });
await addDoc(reviews, { restaurant: rest6, rating: 5, reviewer_id "Hannah" });
await addDoc(reviews, { restaurant: rest6, rating: 4, reviewer_id "Ian" });
await addDoc(reviews, { restaurant: rest7, rating: 5, reviewer_id "Julia" });
다른 컬렉션에서 문서 조회
reviews 컬렉션 그룹에 대한 다음 쿼리는 기본 키 참조를 사용하여 restaurant 컬렉션 그룹을 조회합니다.
Node.js
let results = await execute(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")));
응답
{
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 execute(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")));
응답
{
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 execute(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")));
응답
{
name: "Golden Gate Pizza",
avg_rating: 4.5
},
{
name: "Venice Pizza",
avg_rating: 3.0
}
그룹별 상위 N개 (한도가 있는 하위 쿼리)
다음 쿼리는 restaurants 컬렉션 그룹에서 모든 문서를 가져오고 상관 서브 쿼리를 사용하여 각 레스토랑의 평점이 가장 높은 리뷰 상위 2개를 가져옵니다.
이렇게 하면 리뷰 배열이 너무 커져 쿼리의 메모리 한도에 도달하지 않습니다.
Node.js
let results = await execute(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")));
응답
{
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 execute(db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count")));
응답
{
__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 execute(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)));
응답
{
__name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
}
Anti-Join (NOT EXISTS)
다음 쿼리는 restaurants 컬렉션 그룹을 검색하고 아직 리뷰가 없는 모든 레스토랑을 찾습니다.
Node.js
let results = await execute(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)));
응답
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
조인으로 서브 쿼리
다음 쿼리는 각 피자 가게와 리뷰 간의 관계를 평면화합니다. 하위 쿼리를 unnest(...) 단계 내에 배치하면 서버는 일치하는 각 리뷰에 대해 외부 레스토랑 문서를 복제하여 평면 조인 문서(SQL INNER JOIN와 유사)를 생성합니다.
Node.js
let results = await execute(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")));
응답
{
__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 execute(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");
응답
{
rating: 5,
reviewer_id "Alice"
},
{
rating: 5,
reviewer_id "Diana"
},
{
rating: 5,
reviewer_id "Hannah"
},
{
rating: 5,
reviewer_id "Julia"
}
권장사항
toArrayExpression()로 메모리 관리: 많은 수의 문서를 구체화하면 쿼리 메모리 한도 (128MiB)가 소진될 수 있으므로toArrayExpression()하위 쿼리를 사용할 때 주의해야 합니다. 이 문제를 완화하려면 하위 쿼리 내에서select(...)를 사용하여 필요한 필드만 반환하고where(...)필터를 적용하여 반환되는 문서 수를 제한하세요. 서브 쿼리에서 반환되는 문서 수를 제한하는 것이 적절한 경우limit(...)를 사용하는 것이 좋습니다.- 색인: 하위 쿼리의
where(...)절에 사용되는 필드가 색인되어 있는지 확인합니다. 성능이 우수한 조인은 전체 테이블 스캔이 아닌 색인 검색을 실행하는 기능에 의존합니다.
쿼리 권장사항에 대한 자세한 내용은 쿼리 최적화 가이드를 참고하세요.
제한사항
subcollection(...)범위:subcollection(...)입력 단계는 계층적 관계를 해결하고 조인을 실행하기 위해 상위 문서의 컨텍스트가 필요하므로 하위 쿼리 내에서만 지원됩니다.- 중첩 깊이: 하위 쿼리는 최대 20개 레이어까지 중첩할 수 있습니다.
- 메모리 사용량: 구체화된 데이터에 대한 128MiB 제한은 모든 조인된 문서를 포함한 전체 쿼리에 적용됩니다.