본문으로 건너뛰기

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 Functions를 프로젝트에 구현하세요.

  • 클라이언트 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.js의 SERVER_CALLBACK_AUTH 값과 일치하도록 설정합니다(예: 키 planet과 시크릿 값). 저장하면 LINE Planet 클라우드가 앱 서버를 호출할 수 있고, 앱 서버는 해당 요청이 진짜인지 검증할 수 있습니다.

LPC 콜백 구성

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

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

Firebase SDK 통합​

Android에서는 Gradle에 Firebase Functions와 Messaging을 포함합니다. 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: Swift – Functions 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 앱 서버 구현 가이드에 대한 질문이나 피드백이 있다면 아래 이메일로 연락해 주시기 바랍니다.