맞춤 리졸버를 작성하면 Cloud SQL 외에 다른 데이터 소스를 지원하도록 Firebase Data Connect를 확장할 수 있습니다. 그런 다음 여러 데이터 소스 (Cloud SQL 및 커스텀 리졸버에서 제공하는 데이터 소스)를 단일 쿼리 또는 변형으로 결합할 수 있습니다.
'데이터 소스'의 개념은 유연합니다. 다음이 포함됩니다.
- Cloud Firestore, MongoDB 등 Cloud SQL 이외의 데이터베이스
- Cloud Storage, AWS S3 등과 같은 스토리지 서비스
- Stripe, SendGrid, Salesforce 등 API 기반 통합
- 맞춤 비즈니스 로직
추가 데이터 소스를 지원하는 맞춤 리졸버를 작성하면 Data Connect 쿼리와 변형을 다양한 방식으로 결합하여 다음과 같은 이점을 얻을 수 있습니다.
- 데이터 소스를 위한 통합 승인 레이어 예를 들어 Cloud SQL에 저장된 데이터를 사용하여 Cloud Storage의 파일에 대한 액세스를 승인합니다.
- 웹, Android, iOS용 유형 안전 클라이언트 SDK
- 여러 소스의 데이터를 반환하는 쿼리
- 데이터베이스 상태에 따라 함수 호출이 제한됩니다.
기본 요건
자체 맞춤 리졸버를 작성하려면 다음이 필요합니다.
- Firebase CLI v15.9.0 이상
- Firebase Functions SDK v7.1.0 이상
또한 맞춤 리졸버의 로직을 구현하는 방법인 Firebase용 Cloud Functions를 사용하여 함수를 작성하는 데 익숙해야 합니다.
시작하기 전에
Data Connect를 사용하도록 프로젝트가 이미 설정되어 있어야 합니다.
아직 설정하지 않은 경우 빠른 시작 가이드 중 하나를 따라 설정할 수 있습니다.
맞춤 리졸버 작성
맞춤 리졸버를 작성하는 것은 크게 세 부분으로 나뉩니다. 먼저 맞춤 리졸버의 스키마를 정의하고, 두 번째로 Cloud 함수를 사용하여 리졸버를 구현하고, 마지막으로 쿼리 및 변이에서 맞춤 리졸버 필드를 사용합니다(Cloud SQL 또는 기타 맞춤 리졸버와 함께 사용 가능).
다음 몇 섹션의 단계에 따라 방법을 알아보세요. 동기 부여 예로 Cloud SQL 외부에 저장된 사용자의 공개 프로필 정보가 있다고 가정해 보겠습니다. 이 예에서는 정확한 데이터 스토어가 지정되어 있지 않지만 Cloud Storage, MongoDB 인스턴스 등일 수 있습니다.
다음 섹션에서는 외부 프로필 정보를 Data Connect로 가져올 수 있는 맞춤 리졸버의 스켈레톤 구현을 보여줍니다.
커스텀 리졸버의 스키마 정의
Firebase 프로젝트 디렉터리에서 다음을 실행합니다.
firebase init dataconnect:resolverFirebase CLI에서 맞춤 리졸버의 이름을 묻고 TypeScript 또는 JavaScript로 예시 리졸버 구현을 생성할지 묻습니다. 이 가이드를 따르는 경우 기본 이름을 수락하고 TypeScript 예시를 생성합니다.
그러면 도구에서 빈
dataconnect/schema_resolver/schema.gql파일을 만들고 새 리졸버 구성을dataconnect.yaml파일에 추가합니다.커스텀 리졸버가 제공할 쿼리와 변형을 정의하는 GraphQL 스키마로 이
schema.gql파일을 업데이트합니다. 예를 들어 Cloud SQL이 아닌 데이터 스토어에 저장된 사용자의 공개 프로필을 가져오고 업데이트할 수 있는 맞춤 리졸버의 스키마는 다음과 같습니다.# dataconnect/schema_resolver/schema.gql type PublicProfile { name: String! photoUrl: String! bioLine: String! } type Query { # This field will be backed by your Cloud Function. publicProfile(userId: String!): PublicProfile } type Mutation { # This field will be backed by your Cloud Function. updatePublicProfile( userId: String!, name: String, photoUrl: String, bioLine: String ): PublicProfile }
맞춤 리졸버 로직 구현
다음으로 Cloud Functions를 사용하여 리졸버를 구현합니다. 내부적으로 GraphQL 서버를 만들게 됩니다. 하지만 Cloud Functions에는 이를 처리하는 도우미 메서드 onGraphRequest가 있으므로 데이터 소스에 액세스하는 리졸버 로직만 작성하면 됩니다.
functions/src/index.ts파일을 엽니다.위에서
firebase init dataconnect:resolver를 실행하면 이 명령어가 Cloud Functions 소스 코드 디렉터리를 만들고index.ts의 샘플 코드로 초기화했습니다.다음 정의를 추가합니다.
import { FirebaseContext, onGraphRequest, } from "firebase-functions/dataconnect/graphql"; const opts = { // Points to the schema you defined earlier, relative to the root of your // Firebase project. schemaFilePath: "dataconnect/schema_resolver/schema.gql", resolvers: { query: { // This resolver function populates the data for the "publicProfile" field // defined in your GraphQL schema located at schemaFilePath. publicProfile( _parent: unknown, args: Record<string, unknown>, _contextValue: FirebaseContext, _info: unknown ) { const userId = args.userId; // Here you would use the user ID to retrieve the user profile from your data // store. In this example, we just return a hard-coded value. return { name: "Ulysses von Userberg", photoUrl: "https://example.com/profiles/12345/photo.jpg", bioLine: "Just a guy on a mountain. Ski fanatic.", }; }, }, mutation: { // This resolver function updates data for the "updatePublicProfile" field // defined in your GraphQL schema located at schemaFilePath. updatePublicProfile( _parent: unknown, args: Record<string, unknown>, _contextValue: FirebaseContext, _info: unknown ) { const { userId, name, photoUrl, bioLine } = args; // Here you would update in your datastore the user's profile using the // arguments that were passed. In this example, we just return the profile // as though the operation had been successful. return { name, photoUrl, bioLine }; }, }, }, }; export const resolver = onGraphRequest(opts);
이러한 스켈레톤 구현은 리졸버 함수가 취해야 하는 일반적인 형태를 보여줍니다. 완전히 작동하는 맞춤 리졸버를 만들려면 주석 처리된 섹션을 데이터 소스에서 읽고 쓰는 코드로 채워야 합니다.
쿼리 및 변형에서 맞춤 리졸버 사용
이제 맞춤 리졸버의 스키마를 정의하고 이를 지원하는 로직을 구현했으므로 Data Connect 쿼리 및 변형에서 맞춤 리졸버를 사용할 수 있습니다. 나중에 이러한 작업을 사용하여 Cloud SQL, 맞춤 리졸버 또는 이들의 조합으로 지원되는 모든 데이터에 액세스하는 데 사용할 수 있는 맞춤 클라이언트 SDK를 자동으로 생성합니다.
dataconnect/example/queries.gql에서 다음 정의를 추가합니다.query GetPublicProfile($id: String!) @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") { publicProfile(userId: $id) { name photoUrl bioLine } }이 쿼리는 맞춤 리졸버를 사용하여 사용자의 공개 프로필을 가져옵니다.
dataconnect/example/mutations.gql에서 다음 정의를 추가합니다.mutation SetPublicProfile( $id: String!, $name: String, $photoUrl: String, $bioLine: String ) @auth(expr: "vars.id == auth.uid") { updatePublicProfile(userId: $id, name: $name, photoUrl: $photoUrl, bioLine: $bioLine) { name photoUrl bioLine } }이 변이는 커스텀 리졸버를 다시 사용하여 데이터 스토어에 새 프로필 데이터 세트를 씁니다. 스키마는 사용자가 자신의 프로필만 업데이트할 수 있도록 Data Connect의
@auth지시어를 사용합니다. Data Connect를 통해 데이터 스토어에 액세스하므로 다음과 같은 Data Connect 기능을 자동으로 활용할 수 있습니다.
위 예시에서는 맞춤 리졸버를 사용하여 데이터 스토어의 데이터에 액세스하는 Data Connect 작업을 정의했습니다. 하지만 Cloud SQL 또는 단일 맞춤 데이터 소스의 데이터에 액세스하는 작업은 제한되지 않습니다. 여러 소스의 데이터를 결합하는 고급 사용 사례는 예 섹션을 참고하세요.
그 전에 다음 섹션으로 계속 진행하여 맞춤 리졸버가 작동하는지 확인하세요.
맞춤 리졸버 및 작업 배포
Data Connect 스키마를 변경할 때와 마찬가지로 변경사항을 적용하려면 스키마를 배포해야 합니다. 이렇게 하기 전에 먼저 Cloud Functions를 사용하여 구현한 맞춤 리졸버 로직을 배포하세요.
firebase deploy --only functions이제 업데이트된 스키마와 작업을 배포할 수 있습니다.
firebase deploy --only dataconnectData Connect 스키마를 변경한 후 새 클라이언트 SDK도 생성해야 합니다.
firebase dataconnect:sdk:generate예
이 예에서는 몇 가지 고급 사용 사례를 구현하는 방법과 일반적인 함정을 피하는 방법을 보여줍니다.
Cloud SQL의 데이터를 사용하여 커스텀 리졸버에 대한 액세스 승인
커스텀 리졸버를 사용하여 데이터 소스를 Data Connect에 통합하면 데이터 소스를 결합하는 작업을 작성할 수 있다는 장점이 있습니다.
이 예에서는 소셜 미디어 앱을 빌드하고 있으며, 사용자가 한동안 참여하지 않은 경우 사용자의 친구에게 알림 이메일을 보내는 맞춤 리졸버로 구현된 변이가 있다고 가정합니다.
넛지 기능을 구현하려면 다음과 같은 스키마로 맞춤 리졸버를 만드세요.
# A GraphQL server must define a root query type per the spec.
type Query {
unused: String
}
type Mutation {
sendEmail(id: String!, content: String): Boolean
}
이 정의는 다음과 같은 Cloud 함수로 지원됩니다.
import {
FirebaseContext,
onGraphRequest,
} from "firebase-functions/dataconnect/graphql";
const opts = {
schemaFilePath: "dataconnect/schema_resolver/schema.gql",
resolvers: {
mutation: {
sendEmail(
_parent: unknown,
args: Record<string, unknown>,
_contextValue: FirebaseContext,
_info: unknown
) {
const { id, content } = args;
// Look up the friend's email address and call the cloud service of your
// choice to send the friend an email with the given content.
return true;
},
},
},
};
export const resolver = onGraphRequest(opts);
이메일 전송은 비용이 많이 들고 악용될 수 있으므로 sendEmail 맞춤 리졸버를 사용하기 전에 의도한 수신자가 이미 사용자의 친구 목록에 있는지 확인해야 합니다.
앱에서 친구 목록 데이터가 Cloud SQL에 저장된다고 가정해 보겠습니다.
type User @table {
id: String! @default(expr: "auth.uid")
acceptNudges: Boolean! @default(value: false)
}
type UserFriend @table(key: ["user", "friend"]) {
user: User!
friend: User!
}
먼저 Cloud SQL을 쿼리하여 발신자가 수신자의 친구 목록에 있는지 확인한 후 커스텀 리졸버를 사용하여 이메일을 보내는 변형을 작성할 수 있습니다.
# Send a "nudge" to a friend as a reminder. This will only let the user send a
# nudge if $friendId is in the user's friends list.
mutation SendNudge($friendId: String!) @auth(level: USER_EMAIL_VERIFIED) {
# Step 1: Query and check
query @redact {
userFriend(
key: {userId_expr: "auth.uid", friendId: $friendId}
# This checks that $friendId is in the user's friends list.
) @check(expr: "this != null", message: "You must be friends to nudge") {
friend {
# This checks that the friend is accepting nudges.
acceptNudges @check(expr: "this == true", message: "Not accepting nudges")
}
}
}
# Step 2: Act
sendEmail(id: $friendId, content: "You've been nudged!")
}
참고로 이 예시에서는 맞춤 리졸버의 컨텍스트에 있는 데이터 소스에 데이터베이스 및 유사한 시스템 외의 리소스가 포함될 수 있음을 보여줍니다. 이 예에서 데이터 소스는 클라우드 이메일 전송 서비스입니다.
변형을 사용하여 순차적 실행 보장
데이터 소스를 결합할 때는 한 데이터 소스에 대한 요청이 완료된 후에 다른 데이터 소스에 대한 요청을 해야 하는 경우가 많습니다. 예를 들어 AI API를 사용하여 주문형 동영상을 동적으로 트랜스크립션하는 쿼리가 있다고 가정해 보겠습니다. 이러한 API 호출은 비용이 많이 들 수 있으므로 사용자가 동영상을 소유하거나 사용자가 앱에서 일종의 프리미엄 크레딧을 구매했다는 등의 기준에 따라 호출을 제한하는 것이 좋습니다.
이를 달성하기 위한 첫 번째 시도는 다음과 같을 수 있습니다.
# This won't work as expected.
query BrokenTranscribeVideo($videoId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
# Step 1: Check quota using SQL.
# Verify the user owns the video and has "pro" status or credits.
checkQuota: query @redact {
video(id: $videoId)
{
user @check(expr: "this.id == auth.uid && this.hasCredits == true", message: "Unauthorized access") {
id
hasCredits
}
}
}
# Step 2: Trigger expensive compute
# Only triggers if Step 1 succeeds? No! This won't work because query field
# execution order is not guaranteed.
triggerTranscription: query {
# For example, might call Vertex AI or Transcoder API.
startVideoTranscription(videoId: $videoId)
}
}
이 방법은 쿼리 필드의 실행 순서가 보장되지 않기 때문에 작동하지 않습니다. GraphQL 서버는 동시성을 극대화하기 위해 어떤 순서로든 필드를 확인할 수 있어야 합니다. 반면 GraphQL 서버는 변이의 일부 필드가 해결될 때 부작용이 있을 수 있다고 예상하므로 변이의 필드는 항상 순서대로 해결됩니다.
예시 작업의 첫 번째 단계에는 부작용이 없지만 변형 필드가 순서대로 해결된다는 점을 활용하기 위해 작업을 변형으로 정의할 수 있습니다.
# By using a mutation, we guarantee the SQL check happens FIRST.
mutation TranscribeVideo($videoId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
# Step 1: Check quota using SQL.
# Verify the user owns the video and has "pro" status or credits.
checkQuota: query @redact {
video(id: $videoId)
{
user @check(expr: "this.id == auth.uid && this.hasCredits == true", message: "Unauthorized access") {
id
hasCredits
}
}
}
# Step 2: Trigger expensive compute
# This Cloud Function will ONLY trigger if Step 1 succeeds.
triggerTranscription: query {
# For example, might call Vertex AI or Transcoder API.
startVideoTranscription(videoId: $videoId)
}
}
제한사항
맞춤 리졸버 기능은 실험용 공개 미리보기로 출시됩니다. 현재 다음과 같은 제한사항이 있습니다.
커스텀 리졸버 인수에 CEL 표현식이 없음
맞춤 리졸버의 인수에 CEL 표현식을 동적으로 사용할 수 없습니다. 예를 들어 다음은 불가능합니다.
mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
updateMongoDocument(
collection: "profiles"
# This isn't supported:
id_expr: "auth.uid"
update: { name: $newName }
)
}
대신 표준 변수 (예: $authUid)를 전달하고 안전하게 평가된 @auth(expr: ...) 지시어를 사용하여 작업 수준에서 유효성을 검사합니다.
mutation UpdateMyProfile(
$newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
updateMongoDocument(
collection: "profiles"
id: $authUid
update: { name: $newName }
)
}
또 다른 해결 방법은 모든 로직을 맞춤 리졸버로 이동하고 Cloud Functions에서 모든 데이터 작업을 완료하는 것입니다.
예를 들어 현재 작동하지 않는 다음 예시를 살펴보세요.
mutation BrokenForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
query {
chatMessage(id: $chatMessageId) {
content
}
}
sendEmail(
title: "Forwarded Chat Message"
to_expr: "auth.token.email" # Not supported.
content_expr: "response.query.chatMessage.content" # Not supported.
)
}
대신 Cloud SQL 쿼리와 이메일 서비스 호출을 함수로 지원되는 하나의 변이 필드로 이동합니다.
mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
forwardChatToEmail(
chatMessageId: $chatMessageId
)
}
데이터베이스의 Admin SDK를 생성하고 함수에서 사용하여 Cloud SQL 쿼리를 실행합니다.
const opts = {
schemaFilePath: "dataconnect/schema_resolver/schema.gql",
resolvers: {
query: {
async forwardToEmail(
_parent: unknown,
args: Record<string, unknown>,
_contextValue: FirebaseContext,
_info: unknown
) {
const chatMessageId = args.chatMessageId as string;
let decodedToken;
try {
decodedToken = await getAuth().verifyIdToken(_contextValue.auth.token ?? "");
} catch (error) {
return false;
}
const email = decodedToken.email;
if (!email) {
return false;
}
const response = await getChatMessage({chatMessageId});
const messageContent = response.data.chatMessage?.content;
// Here you call the cloud service of your choice to send the email with
// the message content.
return true;
}
},
},
};
export const resolver = onGraphRequest(opts);
맞춤 리졸버 매개변수에 입력 객체 유형이 없음
커스텀 리졸버는 복잡한 GraphQL 입력 유형을 허용하지 않습니다. 매개변수는 기본 스칼라 유형 (String, Int, Date, Any 등) 및 Enum이어야 합니다.
input PublicProfileInput {
name: String!
photoUrl: String!
bioLine: String!
}
type Mutation {
# Not supported:
updatePublicProfile(userId: String!, profile: PublicProfileInput): PublicProfile
# OK:
updatePublicProfile(userId: String!, name: String, photoUrl: String, bioLine: String): PublicProfile
}
커스텀 리졸버는 SQL 작업보다 먼저 실행될 수 없습니다.
변이에서 표준 SQL 작업 앞에 커스텀 리졸버를 배치하면 오류가 발생합니다. 모든 SQL 기반 작업은 맞춤 리졸버 호출 앞에 표시되어야 합니다.
거래 없음 (@transaction)
맞춤 리졸버는 표준 SQL 작업으로 @transaction 블록 내에 래핑할 수 없습니다. SQL 삽입이 성공한 후 리졸버를 지원하는 Cloud 함수가 실패하면 데이터베이스가 자동으로 롤백되지 않습니다.
SQL과 다른 데이터 소스 간에 트랜잭션 안전성을 확보하려면 SQL 작업 로직을 Cloud 함수 내부로 이동하고 Admin SDK 또는 직접 SQL 연결을 사용하여 유효성 검사와 롤백을 처리하세요.