본문으로 건너뛰기

Firebase로 앱 서버 구현하기

전주헌

안녕하세요. LINE Planet에서 iOS/macOS와 Flutter PlanetKit을 담당하고 있는 전주헌입니다.

LINE Planet은 실시간 서비스형 커뮤니케이션 플랫폼(CPaaS)으로, 실시간 음성 및 화상 통화 기능을 서비스에 도입할 수 있는 클라우드 플랫폼과 클라이언트 SDK를 제공합니다.

LINE Planet을 사용하려는 각 서비스는 LINE Planet과 사용자 및 기기를 연결하기 위한 자체 앱 서버를 애플리케이션 레이어에 구현해야 하는데요. 이번 글에서는 Firebase를 사용해 앱 서버를 구현하는 개략적인 방법을 소개하겠습니다.

앱 서버의 정의와 역할

LINE Planet에서 ‘앱 서버’란 각 사용자가 자신의 서비스를 위해 구현하는 애플리케이션 레이어의 서버를 말합니다.

앱 서버의 주요 역할은 다음과 같습니다.

  • 클라이언트가 LINE Planet을 안전하게 사용할 수 있도록 PlanetKit 액세스 토큰을 발급합니다.
  • LINE Planet으로부터 통화 관련 콜백을 수신합니다.
  • 각자 선택한 푸시 인프라를 사용해 적절한 기기에 통화 수신 알림을 보냅니다.
  • 서비스의 백엔드에서 기기 및 통화 정보를 수집해 작동 상태를 모니터링/디버깅/분석합니다.
DIAGRAM: 앱 서버 역할을 보여주는 통화 수신 알림 처리 예제

Firebase 및 사용 기능 소개

Firebase란 Google에서 제공하는 클라우드 기반 백엔드 서비스 플랫폼(backend as a service, BaaS)으로, 웹이나 모바일 앱 개발 시 주로 사용하는 서버 측 기능들을 미리 구현해 제공하는 서비스입니다. Firebase를 이용하면 인프라 작업을 줄일 수 있고, 간단하게 확장할 수 있는 앱 서버를 구현할 수 있습니다.

이번 글에서 소개할 앱 서버 구현에는 아래 세 가지 Firebase 제품을 사용합니다.

각 제품을 하나씩 간단히 소개하겠습니다.

Cloud Functions와 두 가지 호출 방법

Cloud Functions는 서버 없이 백엔드 코드를 실행할 수 있게 해주는 서비스입니다. 백엔드 코드를 작성해 클라우드 인프라에 저장해 놓고 다른 Firebase 기능이나 DB 변경, HTTPS 호출 등의 이벤트를 트리거로 설정해 놓으면 이벤트 발생 시 해당 코드가 Firebase에서 제공하는 관리형 환경에서 자동으로 실행됩니다.

이 글에서 소개할 앱 서버는 모든 엔드포인트를 Cloud Functions로 구현하며, 이 엔드포인트를 호출하는 방법에는 두 가지가 있습니다.

첫 번째는 직접 HTTPS로 호출하는 것입니다. 각 함수는 다음과 같은 URL로 노출되며, 어떤 HTTP 클라이언트든 이 엔드포인트를 호출할 수 있습니다. 보안을 위해 앱 서버는 단기 앱 서버 액세스 토큰을 발급하며, 토큰 발급 외 다른 HTTP API는 Authorization: Bearer <token>과 함께 호출해야 합니다. 이 방법은 서버 간 통합 및 소프트웨어 도구 개발 시 편리합니다.

[CODE SNIPPET: Example HTTPS endpoint URL]
https://<region>-<project-id>.cloudfunctions.net/registerDevice

두 번째는 Firebase 호출 가능 함수(callable function)를 사용하는 것입니다. Android나 iOS, Web 또는 Flutter에서 클라이언트는 Functions 인스턴스를 얻고 다음과 같이 호출할 수 있습니다. 이 방법은 인증 및 전송을 Firebase가 처리합니다. 따라서 앱 서버에서 클라이언트에게 앱 서버 액세스 토큰을 발급할 필요가 없습니다. Firebase SDK를 지원하는 플랫폼에서 사용할 때 권장하는 방법입니다.

let functions = Functions.functions(region: "asia-northeast1")
let registerDevice = functions.httpsCallable("registerDevice")
registerDevice.call(requestData) { result, error in}

Cloud Firestore

Cloud Firestore는 Firebase에서 제공하는 Google Cloud 기반의 확장 가능한 NoSQL 클라우드 데이터베이스입니다. 여기서는 앱 서버의 데이터베이스로 사용하며, 기기 등록과 수신 통화 엔트리(Firestore 기반 가상 푸시), 통화 이벤트 로그 등을 저장합니다. 정확한 컬렉션 및 문서 구조는 소스 코드에서 커스터마이징할 수 있습니다. 이 글에서는 Firestore가 앱 서버의 상태와 로그를 보관한다는 것을 알면 충분합니다.

FCM

FCM은 Firebase 플랫폼에서 제공하는 무료 메시징 서비스입니다. 이 글에서 구축할 앱 서버에서는 Android나 특정 데스크톱 또는 웹 환경과 같이 FCM을 지원하는 플랫폼에 푸시 알림을 보내는 데 사용합니다. 앱 서버가 수신 통화 콜백을 받으면 FCM을 사용해 대상 기기에 데이터 메시지를 전달해서 클라이언트 앱이 통화 수신 UI를 표시하고 PlanetKit 통화 확인을 진행할 수 있도록 합니다.

LINE Planet Console

LINE Planet Console(이하 LPC)은 LINE Planet SDK를 이용해 개발한 서비스를 관리하기 위한 서비스로, 사용량 및 통화 기록 확인과 서비스 트래픽 모니터링, API key와 콜백 URL 설정 및 관리 기능 등을 제공합니다. 앱 서버 구현 후 배포하려면 LPC에 가입해서 앱 서버를 배포하기 위해 필요한 여러 가지 사항을 준비해야 하는데요. 추후 앱 서버 배포 준비 섹션에서 자세히 설명하겠습니다.

앱 서버 기능 소개

이 섹션에서는 LINE Planet을 PlanetKit과 함께 사용하기 위해 꼭 필요한 핵심 기능을 설명하기 위한 최소한의 앱 서버 설계를 제시합니다. 이 설계는 핵심 클라이언트 API와 서버 콜백을 보여주기 위한 참조 구현입니다.

주의

이 글에서 제시하는 설계는 앱 서버를 구축할 수 있는 여러 접근 방식 중 하나이며, 서비스의 요구사항에 따라 다음과 같은 사항을 추가로 구현해야 할 수 있습니다.

  • 인증(authentication) 및 인가(authorization) 레이어 추가
  • 보다 정교한 기기 관리 구현(예: 사용자별 다중 기기 처리, 기기별 설정)
  • 트래픽 제한 및 어뷰징 방지 기능 추가
  • 통화 라우팅, 사용자 권한, 과금 등을 위한 커스텀 비즈니스 로직 구현
  • 서비스 특성에 맞춘 데이터 모델 확장
  • 이 글에서 다루는 기본 이벤트 로깅 외의 모니터링, 알림, 분석 기능 추가

또한 이 글에서 제공하는 코드 스니펫은 핵심 기능에 집중해 단순화한 예제입니다. 프로덕션 환경에서 사용하기 위해서는 각자의 사용 사례에 맞는 포괄적인 에러 처리나 입력값 검증, 로깅, 보안 조치 등이 필요합니다.

그럼 앱 서버의 기능을 하나씩 살펴보겠습니다.

클라이언트 API

앱 서버는 RegisterDevice, RemoveDevice, IssuePlanetKitAccessToken, IssueAppServerAccessToken의 4개 클라이언트 API를 노출합니다. API는 모두 Cloud Functions로 구현돼 있으며, HTTPS와 Firebase 호출 가능 함수 모두를 통해 접근할 수 있습니다.

RegisterDevice

주어진 사용자와 서비스에 대해 기기를 등록하거나 업데이트합니다. 이를 통해 통화 수신 알림을 등록된 기기에로 잘 전달할 수 있습니다.

  • 요청 파라미터

    • userId: 사용자 식별자
    • serviceId: LPC에서 받은 서비스 식별자
    • deviceType: 기기 플랫폼(iOS, Android, Windows(데스크톱), Linux 등)
    • deviceToken: 푸시 알림 토큰
    • applicationUserData: 커스텀 데이터(선택 사항)
  • 응답 파라미터

    • result: 결과 코드(SUCCESS 혹은 에러 코드)
    • message: 사람이 읽을 수 있는 메시지
    • path: 기기가 등록된 Firestore 문서 경로
예제 코드
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const db = admin.firestore();

const registerDevice = onCall(async (request) => {
const data = request.data;

// 1. 필수 필드 검증 (userId, serviceId, deviceType, deviceToken)
const required = ["userId", "serviceId", "deviceType", "deviceToken"];
for (const field of required) {
if (!data[field] || data[field].trim() === "") {
return {
result: "MISSING_REQUIRED_FIELD",
message: `Missing required field: ${field}`,
};
}
}

// 2. 설정에서 서비스 ID 존재 여부 검증
// 3. 기기 유형 형식 검증
// 4. 기기 토큰 형식 검증

// 5. Firestore에 기기 등록 정보 저장
// 경로: services/{serviceId}/users/{userId}/devices/{deviceType}
const deviceDoc = {
deviceToken: data.deviceToken,
applicationUserData: data.applicationUserData || null,
registeredAt: admin.firestore.FieldValue.serverTimestamp(),
};

await db
.collection("services")
.doc(data.serviceId)
.collection("users")
.doc(data.userId)
.collection("devices")
.doc(data.deviceType)
.set(deviceDoc);

return {result: "SUCCESS", message: "Device registered successfully"};
});

RemoveDevice

기기 등록을 제거합니다. 제거 후 해당 기기는 더 이상 통화 수신 알림을 받지 않습니다.

  • 요청 파라미터

    • userId: 사용자 식별자
    • serviceId: 서비스 식별자
    • deviceType: 제거할 기기 플랫폼
  • 응답 필드

    • result: 결과 코드(SUCCESS, DEVICE_NOT_FOUND 혹은 에러 코드)
    • message: 사람이 읽을 수 있는 메시지
예제 코드
const {onCall} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const db = admin.firestore();

const removeDevice = onCall(async (request) => {
const {userId, serviceId, deviceType} = request.data;

// 1. 필수 필드 검증
const required = ["userId", "serviceId", "deviceType"];
for (const field of required) {
if (!request.data[field] || request.data[field].trim() === "") {
return {
result: "MISSING_REQUIRED_FIELD",
message: `Missing required field: ${field}`,
};
}
}

// 2. 기기 존재 여부 확인
const devicePath = db
.collection("services")
.doc(serviceId)
.collection("users")
.doc(userId)
.collection("devices")
.doc(deviceType);

const deviceDoc = await devicePath.get();
if (!deviceDoc.exists) {
return {result: "DEVICE_NOT_FOUND", message: "Device not found"};
}

// 3. 기기 문서 삭제
await devicePath.delete();

// 4. 남은 기기가 없으면 사용자 문서 정리
const remainingDevices = await db
.collection("services")
.doc(serviceId)
.collection("users")
.doc(userId)
.collection("devices")
.get();

if (remainingDevices.empty) {
await db.collection("services").doc(serviceId)
.collection("users").doc(userId).delete();
}

return {result: "SUCCESS", message: "Device removed successfully"};
});

IssuePlanetKitAccessToken

입력된 사용자와 서비스에 대해 단기(short-lived) PlanetKit 액세스 토큰을 발급합니다. 이 토큰은 PlanetKit으로 통화를 시작, 수락, 참가할 때 사용합니다.

  • 요청 파라미터

    • userId: 사용자 식별자
    • serviceId: 서비스 식별자
  • 응답 파라미터

    • result: 결과 코드(SUCCESS 혹은 에러 코드)
    • message: 사람이 읽을 수 있는 메시지
    • accessToken: PlanetKit 인증을 위한 JWT 토큰
예제 코드
const {onCall} = require("firebase-functions/v2/https");
const jwt = require("jsonwebtoken");

// 서비스 설정은 보안 설정 파일에서 로드되어야 함
// 예시 구조: {"serviceId": {"apiKey": "...", "apiSecret": "..."}}
const serviceConfigs = require("./config/services.json");

const issuePlanetKitAccessToken = onCall(async (request) => {
const {userId, serviceId} = request.data;

// 1. 필수 필드 검증
if (!userId || !serviceId) {
return {
result: "MISSING_REQUIRED_FIELD",
message: "userId and serviceId are required",
};
}

// 2. 서비스 설정에서 API 인증정보(credential) 가져오기
const credentials = serviceConfigs[serviceId];
if (!credentials || !credentials.apiKey || !credentials.apiSecret) {
return {
result: "INVALID_SERVICE_ID",
message: "Service configuration not found",
};
}

// 3. JWT 페이로드 생성
const payload = {
sub: serviceId, // 제목: serviceId
iss: credentials.apiKey, // 발급자: LPC에서 받은 apiKey
iat: Math.floor(Date.now() / 1000), // 발급 시각
uid: userId, // 사용자 식별자
};

// 4. LPC에서 받은 API 시크릿을 사용하여 HMAC256으로 서명
const token = jwt.sign(payload, credentials.apiSecret, {
algorithm: "HS256",
header: {typ: "JWT"},
});

return {
result: "SUCCESS",
message: "Access token generated successfully",
accessToken: token,
};
});

IssueAppServerAccessToken(선택 사항)

다른 앱 서버 API에 직접 HTTPS 요청을 보내는 경우에 인증하기 위한 단기 앱 서버 액세스 토큰을 발급합니다. Firebase 호출 가능 함수를 사용하지 않는 경우에만 필요합니다.

  • 요청 파라미터

    • serviceId: 서비스 식별자
    • APIKey: LPC에서 받은 인증을 위한 API 키
  • 응답 파라미터

    • result: 결과 코드(SUCCESS, INVALID_API_KEY 혹은 에러 코드)
    • message: 사람이 읽을 수 있는 메시지
    • accessToken: 앱 서버 API 인증을 위한 JWT 토큰
예제 코드
const {onRequest} = require("firebase-functions/v2/https");
const jwt = require("jsonwebtoken");

// 서비스 설정은 보안 설정 파일에서 로드되어야 함
const serviceConfigs = require("./config/services.json");

const issueAppServerAccessToken = onRequest(async (req, res) => {
const {serviceId, APIKey} = req.body;

// 1. API 키가 서비스 설정과 일치하는지 검증
const credentials = serviceConfigs[serviceId];
if (!credentials || APIKey !== credentials.apiKey) {
return res.status(401).json({
result: "INVALID_API_KEY",
message: "Invalid API key",
});
}

// 2. 만료 시간과 함께 JWT 생성 (예: 1시간)
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: serviceId,
iss: "app-server",
iat: now,
exp: now + 3600, // 만료 시간: 1시간
type: "app-server-token",
};

const token = jwt.sign(payload, credentials.apiSecret, {
algorithm: "HS256",
});

res.json({
result: "SUCCESS",
message: "App server access token generated successfully",
accessToken: token,
});
});

서버 콜백

LINE Planet 클라우드는 통화 관련 이벤트가 발생하면 앱 서버를 호출합니다. 이를 서버 콜백이라고 하며, 쿼리 파라미터가 포함된 GET 요청을 받는 HTTPS 트리거를 지원하는 Cloud Functions로 구현합니다.

전체 페이로드 구조와 필드 사양은 LINE Planet Callbacks 문서를 참고하세요.

notifyCallback

사용자에게 전화가 걸려올 때 호출됩니다. 이 콜백이 호출되면 앱 서버는 해당 사용자로 등록된 기기를 조회하고 APNs나 FCM을 통해 알림을 보내 클라이언트가 통화 수신 화면을 표시하고 PlanetKit으로 통화를 검증할 수 있도록 해야 합니다.

  • 요청 파라미터(GET 쿼리 문자열)
    • sid: 세션 ID(36자)
    • from_service_id: 발신자의 서비스 ID
    • from_user_id: 발신자의 사용자 ID
    • to_service_id: 착신자의 서비스 ID
    • to_user_id: 착신자의 사용자 ID
    • type: 통화 유형('A': 음성, 'V': 영상)
    • param: cc_param으로 PlanetKit으로 전달해야 하는 통화 검증 파라미터
    • app_svr_data: 애플리케이션 데이터(선택 사항)
  • 인증
    • LPC에서 설정된 키-값 쌍을 포함한 커스텀 헤더
예제 코드
const {onRequest} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const db = admin.firestore();

// 콜백을 위한 인증 설정
const CALLBACK_AUTH_HEADER = "your-callback-auth-header";
const CALLBACK_AUTH_VALUE = "your-secret-value";

const notifyCallback = onRequest({cors: true}, async (req, res) => {
// 1. 인증 헤더 검증
const authHeader = req.headers[CALLBACK_AUTH_HEADER.toLowerCase()];
if (authHeader !== CALLBACK_AUTH_VALUE) {
return res.status(401).json({
result: "INVALID_CALLBACK_AUTH",
message: "Invalid callback authentication",
});
}

// 2. 쿼리 파라미터 파싱 및 검증
const {sid, from_service_id, from_user_id, to_service_id,
to_user_id, type, param, app_svr_data} = req.query;

// 3. 두 사용자 모두 등록된 기기에 존재하는지 검증
const fromUserExists = await checkUserExists(from_service_id, from_user_id);
const toUserExists = await checkUserExists(to_service_id, to_user_id);

if (!fromUserExists || !toUserExists) {
return res.status(404).json({
result: "USER_NOT_FOUND",
message: "User not found",
});
}

// 4. 로깅을 위해 콜백 데이터를 Firestore에 저장
const callbackDoc = {
sid,
from_service_id,
from_user_id,
to_service_id,
to_user_id,
type,
cc_param: param, // 명확성을 위해 이름 변경
app_svr_data,
receivedAt: admin.firestore.FieldValue.serverTimestamp(),
};

await db.collection("notify_callbacks").add(callbackDoc);

// 5. 착신자의 기기로 푸시 알림 전송
// 이 함수는 to_user_id의 모든 기기를 조회하고 다음을 전송:
// - iOS 기기용 APNs VoIP 푸시
// - Android 기기용 FCM 데이터 메시지
const pushResult = await sendPushNotificationsToUser(
to_service_id,
to_user_id,
callbackDoc,
sid
);

res.json({
result: "SUCCESS",
message: "Notify callback processed successfully",
pushNotifications: pushResult,
});
});

// 사용자 존재 여부를 확인하는 헬퍼 함수
async function checkUserExists(serviceId, userId) {
const userDoc = await db
.collection("services")
.doc(serviceId)
.collection("users")
.doc(userId)
.get();
return userDoc.exists;
}

callEventCallback

1:1 통화가 종료될 때 호출됩니다. 이때 앱 서버는 시작 시간, 종료 시간, 지속 시간, 통화 종료 이유와 같은 통화 통계를 Firestore에 기록해 추후 모니터링 및 분석에 사용할 수 있습니다. 각 파라미터의 자세한 정보는 1대1 통화 이벤트 콜백 문서를 참고하세요.

  • 요청 파라미터(GET 쿼리 문자열)
    • sid: 세션 ID
    • from_service_id, from_user_id: 발신자 식별자
    • to_service_id, to_user_id: 착신자 식별자
    • type: 미디어 유형('A' 혹은 'V')
    • setup_time, start_time, end_time: 통화 생성, 시작, 종료 시각(Unix 타임스탬프)
    • duration: 통화 지속 시간(초)
    • srcip, dstip: 발신자, 착신자 IP 주소
    • terminate: Q.850 원인 코드
    • rel_code: 통화 상세 해제 코드
    • rel_code_str: 통화 상세 해제 메시지
    • billing_sec: 사용자에게 요금이 청구되는 통화 시간(초)
    • disconnect_reason: 통화 종료 이유
    • releaser_type: 통화 종료 소스 유형
    • 그 외 추가 가능 필드: rc_idc(발신자를 처리한 IDC), user_rel_code(애플리케이션이 정의한 통화 해제 코드), app_svr_data(애플리케이션 데이터) 등
예제 코드
const {onRequest} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const db = admin.firestore();

// 콜백을 위한 인증 설정
const CALLBACK_AUTH_HEADER = "your-callback-auth-header";
const CALLBACK_AUTH_VALUE = "your-secret-value";

const callEventCallback = onRequest({cors: true}, async (req, res) => {
// 1. 인증 헤더 검증
const authHeader = req.headers[CALLBACK_AUTH_HEADER.toLowerCase()];
if (authHeader !== CALLBACK_AUTH_VALUE) {
return res.status(401).json({
result: "INVALID_CALLBACK_AUTH",
message: "Invalid callback authentication",
});
}

// 2. 쿼리 파라미터 파싱
const data = req.query;

// 3. 사용자 존재 여부 검증
const fromUserExists = await checkUserExists(
data.from_service_id,
data.from_user_id
);
const toUserExists = await checkUserExists(
data.to_service_id,
data.to_user_id
);

if (!fromUserExists || !toUserExists) {
return res.status(404).json({
result: "USER_NOT_FOUND",
message: "User not found",
});
}

// 4. 분석을 위해 통화 이벤트를 Firestore에 저장
const callEventDoc = {
sid: data.sid,
from_service_id: data.from_service_id,
from_user_id: data.from_user_id,
to_service_id: data.to_service_id,
to_user_id: data.to_user_id,
type: data.type,
setup_time: parseInt(data.setup_time, 10),
start_time: parseInt(data.start_time, 10),
end_time: parseInt(data.end_time, 10),
duration: parseInt(data.duration, 10),
srcip: data.srcip,
dstip: data.dstip,
terminate: parseInt(data.terminate, 10),
rel_code: parseInt(data.rel_code, 10),
rel_code_str: data.rel_code_str,
billing_sec: parseInt(data.billing_sec, 10),
disconnect_reason: parseInt(data.disconnect_reason, 10),
releaser_type: parseInt(data.releaser_type, 10),
receivedAt: admin.firestore.FieldValue.serverTimestamp(),
};

await db.collection("call_events").add(callEventDoc);

res.json({
result: "SUCCESS",
message: "Call event callback processed successfully",
});
});

// 사용자 존재 여부를 확인하는 헬퍼 함수
async function checkUserExists(serviceId, userId) {
const userDoc = await db
.collection("services")
.doc(serviceId)
.collection("users")
.doc(userId)
.get();
return userDoc.exists;
}

groupCallEventCallback

참가자 참여, 퇴장 또는 통화 종료와 같은 그룹 통화 이벤트 발생 시 호출됩니다. 이때 앱 서버는 그룹 통화 작동을 분석하기 위한 세션별 요약 정보와 세부 이벤트 로그를 보관합니다. 각 파라미터의 자세한 사항은 그룹 통화 이벤트 콜백 문서를 참고하세요.

  • 요청 파라미터(GET 쿼리 문자열)
    • sid: 세션 ID(그룹 통화용 32~36자)
    • svc_id: 그룹 통화 서비스 ID
    • id: 그룹 통화 ID
    • user_svc_id, user_id: 참여자 식별자
    • host_svc_id, host_id: 호스트 식별자
    • sc: 그룹 통화 상태 코드('S': 시작됨, 'C': 변경됨, 'E': 종료됨)
    • msc: 참여자 상태 코드('C': 연결됨, 'D': 연결 해제됨, 'T': 타임아웃, 'M': 미디어 변경됨)
    • setup_time, start_time, end_time: 통화 생성, 시작, 종료 시각
    • online: 온라인 참여자수
    • media_type: 미디어 유형('A' 혹은 'V')
    • ts: 이벤트 타임스탬프
    • 그 외 추가 가능 필드: ue_type(사용자 단말 유형), display_name(사용자 단말 표시 이름), mtg_data(미팅 생성 시 지정한 미팅 데이터) 등
예제 코드
const {onRequest} = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const db = admin.firestore();

// 콜백을 위한 인증 설정
const CALLBACK_AUTH_HEADER = "your-callback-auth-header";
const CALLBACK_AUTH_VALUE = "your-secret-value";

const groupCallEventCallback = onRequest({cors: true}, async (req, res) => {
// 1. 인증 헤더 검증
const authHeader = req.headers[CALLBACK_AUTH_HEADER.toLowerCase()];
if (authHeader !== CALLBACK_AUTH_VALUE) {
return res.status(401).json({
result: "INVALID_CALLBACK_AUTH",
message: "Invalid callback authentication",
});
}

// 2. 쿼리 파라미터 파싱 및 검증
const data = req.query;

// 3. 사용자와 호스트 존재 여부 검증
const userExists = await checkUserExists(data.user_svc_id, data.user_id);
const hostExists = await checkUserExists(data.host_svc_id, data.host_id);

if (!userExists || !hostExists) {
return res.status(404).json({
result: "USER_NOT_FOUND",
message: "User not found",
});
}

// 4. 누적을 위해 세션 기반 구조로 이벤트 저장
// 구조: group_call_events/{sid}/events/{auto-id}
const groupCallEventDoc = {
sid: data.sid,
svc_id: data.svc_id,
id: data.id,
user_svc_id: data.user_svc_id,
user_id: data.user_id,
host_svc_id: data.host_svc_id,
host_id: data.host_id,
sc: data.sc,
msc: data.msc,
setup_time: parseInt(data.setup_time, 10),
start_time: parseInt(data.start_time, 10),
end_time: parseInt(data.end_time, 10),
online: parseInt(data.online, 10),
media_type: data.media_type,
ts: parseInt(data.ts, 10),
receivedAt: admin.firestore.FieldValue.serverTimestamp(),
};

const sessionRef = db.collection("group_call_events").doc(data.sid);

// 서브컬렉션에 이벤트 추가
await sessionRef.collection("events").add(groupCallEventDoc);

// 5. 세션 요약 문서 업데이트
const sessionSummary = {
sid: data.sid,
svc_id: data.svc_id,
group_call_id: data.id,
host_svc_id: data.host_svc_id,
host_id: data.host_id,
media_type: data.media_type,
latest_sc: data.sc,
latest_online_count: parseInt(data.online, 10),
setup_time: parseInt(data.setup_time, 10),
latest_event_time: parseInt(data.ts, 10),
call_ended: data.sc === "E",
lastUpdatedAt: admin.firestore.FieldValue.serverTimestamp(),
total_events: admin.firestore.FieldValue.increment(1),
};

await sessionRef.set(sessionSummary, {merge: true});

res.json({
result: "SUCCESS",
message: "Group call event callback processed successfully",
});
});

// 사용자 존재 여부를 확인하는 헬퍼 함수
async function checkUserExists(serviceId, userId) {
const userDoc = await db
.collection("services")
.doc(serviceId)
.collection("users")
.doc(userId)
.get();
return userDoc.exists;
}

앱 서버 배포 준비

앱 서버를 배포하기 전에 두 가지를 준비해야 합니다. 하나는 LINE Planet 프로젝트 설정을 위한 LINE Planet Console(이하 LPC)에 가입해 필요한 것들을 준비하는 것이고, 다른 하나는 앱 서버를 호스팅하기 위한 Firebase를 준비하는 것입니다. 두 가지가 준비돼야 앱 서버 소스 코드가 구성됩니다. 각각을 어떻게 준비해야 하는지 살펴보겠습니다.

LPC

LPC 가입 및 로그인

LPC는 LINE Planet 프로젝트를 생성하고 관리하는 곳입니다. 브라우저에서 아래 LPC 웹 사이트에 접속해 가입 후 로그인합니다.

서비스 ID 획득

LINE Planet 연동 시 LINE Planet에서 제공하는 두 가지 실행 환경(Evaluation/Real)을 선택해 적용합니다.

  • Evaluation 환경(평가)
    • 'Evaluation 환경'은 LINE Planet에서 제공하는 별도 검증/테스트용 환경을 의미합니다(각 서비스 사에서 별도로 운영하는 평가(스테이징) 환경을 가리키는 것이 아닙니다). 실제 운영 트래픽 적용 전 연동 개발, 기능 검증, QA, 사내 테스트 등에 사용할 수 있습니다.
    • LPC에서 서비스 ID를 획득할 수 있습니다.
  • Real 환경(운영)
    • 실제 사용자 트래픽을 처리하는 운영(프로덕션) 환경으로 서비스 오픈 및 운영 단계에서 사용합니다.
    • Real 환경은 LINE Planet 팀(이메일: dl_planet_help@linecorp.com)에 문의 후 이용할 수 있습니다.

권장 흐름은 Evaluation 환경에서 연동 및 검증을 완료한 뒤 운영 준비가 완료됐을 때 Real 환경으로 전환하는 방식입니다.

스크린숏: LPC 서비스 ID 획득 메뉴

API 키 및 시크릿 발급

동일한 LPC 프로젝트에서 서비스를 위한 API 키와 API 시크릿을 발급합니다. API 키와 API 시크릿은 앱 서버가 서비스를 위해 LINE Planet 클라우드와 통신할 때 사용하는 것으로 이후 앱 서버 구성 시 필요하니 안전하게 저장해 놓습니다.

스크린숏: API 키 및 시크릿 구성을 보여주는 LPC 화면

참고: 이 대화 상자를 닫기 전에 꼭 API 시크릿을 복사해 저장해야 합니다. 대화 상자를 닫으면 다시 이 API 시크릿을 볼 수 없습니다.

Firebase

Firebase 프로젝트 준비

서비스에서 이미 Firebase를 사용하고 있다면 앱 서버에 기존 프로젝트를 재사용할 수 있습니다. 이 경우 프로젝트 생성을 건너뛰고 앱이 이미 연결된 기존 Firebase 프로젝트를 사용합니다. 아직 Firebase 프로젝트가 없다면 LINE Planet 앱 서버 전용 새 프로젝트를 생성합니다. 이 프로젝트는 앱 서버에서 사용하는 Cloud Functions와 Firestore 데이터베이스를 호스팅하기 위한 것입니다.

자세한 사항은 다음 Firebase 공식 가이드를 참조하세요.

Firebase Cloud Functions 활성화

Firebase 콘솔에서 Functions 섹션을 엽니다. 필요한 경우 Cloud Functions 사용을 허용하는 요금제(예: Blaze)로 전환합니다. Functions 섹션에서 Cloud Functions가 활성화돼 있고 Firebase CLI가 이 프로젝트에 배포할 수 있는지 확인합니다.

자세한 사항은 다음 Firebase 공식 가이드를 참조하세요.

Firestore 활성화

Firestore Database 섹션을 열고 아직 활성화되지 않은 경우 데이터베이스를 생성합니다. asia-northeast1과 같은 적절한 지역을 선택하고 초기 보안 모드를 설정합니다. 이 데이터베이스는 앱 서버에서 기기 등록 및 통화 이벤트를 저장하는 데 사용됩니다.

자세한 사항은 다음 Firebase 공식 가이드를 참조하세요.

원하는 플랫폼에 대한 푸시 설정

iOS 및 iPadOS의 경우 Apple Developer 포털에서 APNs Auth Key(.p8)를 얻은 다음 Apple 플랫폼용 FCM을 구성합니다. Android의 경우 Firebase에 앱을 등록하고 google-services.json을 다운로드한 다음 클라이언트 프로젝트에서 FCM을 구성합니다. 데스크톱 또는 기타 플랫폼의 경우 FCM 또는 Firestore 기반 알림을 사용할지 결정합니다.

설정 단계는 다음 Firebase 공식 가이드를 참조하세요.

앱 서버 배포

앞서 앱 서버 기능 소개 섹션에서 소개한 패턴으로 앱 서버 함수를 모두 구현됐다면 이제 앱 서버 함수를 Firebase에 배포하고 LINE Planet 클라우드에 연결할 수 있습니다.

Cloud Functions 구현

앱 서버 기능 소개 섹션에서 소개한 코드 스니펫을 참고해 아래 Firebase용 Cloud Funtions를 프로젝트에 구현하세요.

  • 클라이언트 API
    • registerDevice(Firebase 호출 가능 함수)
    • registerDeviceHttp(인증 방식 HTTP)
    • removeDevice(Firebase 호출 가능 함수)
    • removeDeviceHttp(인증 방식 HTTP)
    • issuePlanetKitAccessToken(Firebase 호출 가능 함수)
    • issuePlanetKitAccessTokenHttp(인증 방식 HTTP)
    • issueAppServerAccessToken(HTTP)
  • 서버 콜백
    • notifyCallback(HTTP GET 엔드포인트)
    • callEventCallback(HTTP GET 엔드포인트)
    • groupCallEventCallback(HTTP GET 엔드포인트)
  • 필수 설정
    • 서버 콜백 인증 헤더 설정(키-값 쌍)
    • 서비스 크리덴셜 설정(LPC에서 받은 API 키와 API 시크릿)
    • iOS 푸시를 위한 APNs 크리덴셜 설정(iOS 지원 시)
    • 기기 등록 및 이벤트 로깅을 위한 Firestore 컬렉션 설정

Cloud Functions 배포

다음 명령어를 functions 디렉토리에서 Firebase CLI를 이용해 실행해 모든 함수를 배포합니다.

firebase deploy --only functions

특정 함수만 배포하고 싶을 때에는 다음과 같이 실행합니다.

firebase deploy --only functions:registerDevice

로그는 다음 명령어로 확인할 수 있습니다.

firebase functions:log

콜백 및 헤더 등록

Cloud Functions를 배포한 뒤 Firebase 콘솔의 Functions 탭에 URL이 표시되면 LPC에 콜백을 등록해야 합니다.

Firebase Functions 탭

LPC에서 서비스의 콜백 구성 페이지를 열고 다음을 설정합니다.

  • notifyCallback URL을 해당 Cloud Function으로 설정
  • callEventCallback URL을 해당 Cloud Function으로 설정
  • 그룹 통화를 사용하는 경우 groupCallEventCallback URL 설정

설정을 완료한 뒤 인증 헤더를 constants.jsSERVER_CALLBACK_AUTH 값과 일치하도록 설정합니다(예: 키 planet과 시크릿 값). 저장하면 LINE Planet 클라우드가 앱 서버를 호출할 수 있고, 앱 서버는 해당 요청이 진짜인지 검증할 수 있습니다.

LPC 콜백 구성

클라이언트 앱과 앱 서버 연동하기

배포 및 콜백 등록을 완료하고 나면 클라이언트 애플리케이션에서 앱 서버를 사용할 수 있습니다.

Firebase SDK 통합

Android에서는 Gradle에 Firebase FunctionsMessaging을 포함합니다. iOS에서는 CocoaPods(또는 Swift Package Manager)를 통해 FirebaseCore, FirebaseFunctions, FirebaseMessaging을 포함합니다. 그런 다음 Firebase를 초기화하고 적절한 지역에서 Functions 인스턴스를 생성합니다.

[CODE SNIPPET: Android Firebase dependencies]
implementation platform("com.google.firebase:firebase-bom:<VERSION>")
implementation "com.google.firebase:firebase-functions"
implementation "com.google.firebase:firebase-messaging"
[CODE SNIPPET: iOS Firebase dependencies]
pod 'Firebase/Core'
pod 'Firebase/Functions'
pod 'Firebase/Messaging'
[CODE SNIPPET: SwiftFunctions instance]
import FirebaseFunctions

let functions = Functions.functions(region: "asia-northeast1")
[CODE SNIPPET: Kotlin – Functions instance]
import com.google.firebase.functions.ktx.functions
import com.google.firebase.ktx.Firebase

val functions = Firebase.functions("asia-northeast1")

Swift/Kotlin 예제: 통화 발신(통화 생성 및 검증)

클라이언트 측의 기본 패턴은 간단합니다.

  • 사용자가 로그인하고 푸시 토큰을 사용할 수 있게 되면 기기를 등록합니다.
  • 통화를 걸거나 확인하기 전에 PlanetKit 액세스 토큰을 요청합니다.
  • 토큰과 콜백 매개변수를 PlanetKit에 전달합니다.

Swift 예제

[CODE SNIPPET: Swift – register device and issue token]

// 사용자 로그인 시 또는 푸시 토큰 업데이트 시 registerDevice를 호출합니다.
let registerDevice = functions.httpsCallable("registerDevice")
let registerData: [String: Any] = [
"userId": "user123",
"serviceId": "<SERVICE_ID>",
"deviceType": "IOS",
"pushType": "PUSHKIT",
"deviceToken": "<PUSH_TOKEN>",
"pushkitEnvironment": "DEBUG"
]
registerDevice.call(registerData) { result, error in
// 결과 또는 오류 처리
}

// 통화를 걸거나 확인하기 전에 issuePlanetKitAccessToken을 호출합니다.
let issueToken = functions.httpsCallable("issuePlanetKitAccessToken")
let tokenData: [String: Any] = [
"userId": "user123",
"serviceId": "<SERVICE_ID>"
]
issueToken.call(tokenData) { result, error in
// "accessToken"을 추출하여 PlanetKit에 전달합니다.
}

Kotlin 예제

// 사용자 로그인 시 또는 푸시 토큰 업데이트 시 registerDevice를 호출합니다.
val registerData = hashMapOf(
"userId" to "user123",
"serviceId" to "<SERVICE_ID>",
"deviceType" to "ANDROID",
"pushType" to "FCM",
"deviceToken" to "<FCM_TOKEN>"
)
functions
.getHttpsCallable("registerDevice")
.call(registerData)
.addOnSuccessListener { /* 성공 처리 */ }
.addOnFailureListener { /* 오류 처리 */ }

// 통화를 걸거나 확인하기 전에 issuePlanetKitAccessToken을 호출합니다.
val tokenData = hashMapOf(
"userId" to "user123",
"serviceId" to "<SERVICE_ID>"
)
functions
.getHttpsCallable("issuePlanetKitAccessToken")
.call(tokenData)
.addOnSuccessListener { result ->
// "accessToken"을 추출하여 PlanetKit에 전달합니다.
}
.addOnFailureListener { /* 오류 처리 */ }

통화 수신도 위 패턴과 유사합니다. LINE Planet 클라우드가 notifyCallback을 호출하면, 앱 서버가 푸시를 보내고, 클라이언트는 이를 받아 통화 수신 UI를 표시한 다음 PlanetKit 액세스 토큰을 가져와서 PlanetKit을 통해 통화를 확인합니다.

통화 검증 시 cc_param과 같은 필드를 어떻게 사용하는지 등 PlanetKit의 상세한 사용 방법은 PlanetKit SDK 문서를 참고하시기 바랍니다.

마치며

이번 글에서는 Firebase를 사용해 LINE Planet 활용에 필요한 앱 서버를 구현할 수 있는 개략적인 가이드를 소개했습니다. Firebase를 사용해 앱 서버를 구현하면 다음과 같은 이점을 누릴 수 있습니다.

  • 작업 효율 향상: 서버 프로비저닝이나 OS 관리, 인증 등 서버 구축 및 운영에 필요한 여러 번거로운 작업을 생략할 수 있습니다.
  • 확장성 향상: Firebase는 트래픽 정도에 따라 서버 리소스를 자동으로 확장하므로 트래픽이 갑자기 증가해도 별도 조치 없이 안정적으로 서비스를 제공할 수 있습니다.

이런 이점 덕분에 개발자는 인프라 관리에 시간을 쓰지 않고 핵심 비즈니스 로직에 더욱 집중할 수 있습니다. 이 글에서는 Firebase를 중심으로 설명했지만 아키텍처 패턴과 핵심 로직은 AWS Lambda나 Azure Functions 등 다른 서버리스 플랫폼에도 적용할 수 있으며, API 설계와 콜백 흐름을 원하는 환경에 맞게 응용할 수 있습니다.

마지막으로 이 글을 주요 포인트 중심으로 요약하면 다음과 같습니다.

  • 앱 서버의 역할
    • 클라이언트 API 제공
    • LINE Planet 클라우드로부터 콜백 수신
    • 푸시를 통해 통화 수신 알림을 착신 측(클라이언트) 엡에 전달
  • 앱 서버 구현에 두 가지 방식(HTTPS, Firebase 호출 가능 함수)으로 호출할 수 있는 Firebase Cloud Functions를 사용하고, 앱 서버의 데이터 저장소로는 Cloud Firestore 사용
  • LINE Planet에서 요구하는 앱 서버 기능을 위한 최소한의 API 설계
    • 클라이언트 API (RegisterDevice, RemoveDevice, IssuePlanetKitAccessToken, IssueAppServerAccessToken)
      • 핵심 검증, 토큰 생성, Firesotre 작업을 확인할 수 있는 코드 스니펫 포함
    • 서버 콜백 (notifyCallback, callEventCallback, groupCallEventCallback)
      • 인증, 데이터 검증, 이벤트 로깅, 푸시 알림 패턴을 확인할 수 있는 코드 스니펫 포함
  • LPC와 Firebase를 준비하고, 함수를 구현한 뒤 배포하고, 콜백을 등록하는 구체적인 과정
  • 클라이언트 앱이 Firebase SDK를 통해 앱 서버를 호출한 뒤 PlanetKit과 함께 그 결과를 사용하는 방법을 파악하기 위한 기본적인 예제

이 글에서 소개한 Firebase 기반 LINE Planet 앱 서버 구현 가이드에 대한 질문이나 피드백이 있다면 아래 이메일로 연락해 주시기 바랍니다.