들어가며
LINE Developers를 통해 이용할 수 있는 LIFF(LINE Front-end Framework)를 활용하면, LINE 앱 사용자를 대상으로 나만의 서비스 공간을 열 수 있습니다. LINE 앱 안에서 웹 서비스가 그대로 실행되기 때문에 LINE 공식 계정(LINE Official Account, 이하 LINE OA)(참고)이 단순한 알림 채널을 넘어 고객과 직접 연결되는 플랫폼이 될 수 있습니다.
저희는 이 가능성을 직접 확인해 봤습니다. LINE Planet 팀에서 PM인 정덕범과 Android 엔지니어인 강대경, 단 둘이서, 웹 엔지니어 없이 LINE OA에서 작동하는 그룹 영상 통화 서비스를 만들었습니다.
이 글은 그 과정의 전체 구조와 핵심 구현 포인트를 따라 할 수 있도록 정리한 기록입니다.
LINE OA와 LIFF를 조합해 어떤 서비스를 만들 수 있을까?
LINE OA는 사용자와의 접점을, LIFF는 그 안에서 작동하는 웹 화면을 담당합니다. 이미 LINE OA를 운영하고 있거나, LINE 앱 사용자를 대상으로 새로운 서비스를 고민하고 있다면 다음과 같은 서비스를 LINE 앱 안에서 바로 시작할 수 있습니다.
- 전문 상담: 변호사, 재무 설계사, 심리 상담사 등의 전문가가 LINE OA를 통해 고객과 1:1 화상 상담을 진행할 수 있습니다. 고객은 별도 앱 설치 없이 LINE 앱 안에서 바로 상담방에 입장합니다.
- 원격 교육: 튜터와 학생이 LINE OA를 통해 수업을 예약하고, 지정된 시간에 화상 수업 방에 바로 입장할 수 있습니다. 여러 선생님이 같은 LINE OA를 사용하더라도 동시에 각각 독립적인 통화가 진행됩니다. 여기에 화면 공유까지 활용하면 문서나 문제 풀이도 함께 볼 수 있습니다.
- 실시간 소통 방송: LINE OA 팔로워를 대상으로 LINE 앱 안에서 실시간 방송을 진행할 수 있습니다. 기본 500명에서 최대 1만 명까지 동시 참여가 가능해 소규모 팬미팅부터 대규모 라이브 이벤트까지 같은 구조로 운영할 수 있습니다. clubhouse처럼 청중이 패널로 올라와 진행자와 실시간으로 대화하는 양방향 소통 형식도 구현할 수 있어, 일방적 방송이 아닌 커뮤니티 중심의 대화 공간을 만들 수 있습니다.
- 게임 인앱 음성 채팅: 게임 플레이 중 팀원과 실시간으로 음성으로 소통할 수 있습니다. LINE 친구와 함께 게임하면서 별도 앱 전환 없이 바로 대화할 수 있습니다.
LINE OA와 LIFF 조합으로 그룹 영상 통화 서비스 만들기 개요
그룹 영상 통화 서비스라고 하니 복잡하게 느껴질 수 있지만 실제로 직접 만들어야 하는 것은 다음 두 가지뿐입니다.
- 웹 앱: LIFF에서 실행할, LINE Planet SDK를 사용한 그룹 영상 통화 웹 앱
- 앱 서버: LINE Planet 액세스 토큰 발급
LINE OA, LIFF, LINE Planet이 각자 가장 어려운 부분인 LINE 인증, WebRTC 미디어 처리, 글로벌 네트워크 인프라를 알아서 처리해 줍니다. 우리가 할 일은 연결하는 것뿐입니다. 앱 서버는 Firebase용 Cloud Functions를 활용하면 서버 인프라 설정 없이 빠르게 구성할 수 있습니다(자세한 내용은 LINE Planet Docs 사이트에 발행된 Firebase로 앱 서버 구축하기를 참고하세요).
각 컴포넌트의 역할은 다음과 같습니다.
| 컴포넌트 | 역할 |
|---|---|
| LINE OA | 사용자 진입점 담당, 탭으로 LIFF 앱 실행 |
| LIFF | LINE 앱 내 웹뷰 담당, LINE 로그인 토큰과 사용자 프로필 전달 |
| 웹 앱(React와 Vite) | UI 및 비즈니스 로직 담당, 통화방 생성·참가 및 미디어 제어 |
| 앱 서버 | LINE Planet 액세스 토큰 발급 엔드포인트 |
| LINE Planet | WebRTC 기반 실시간 통신 인프라 |
LIFF를 활용하는 이유는 명확합니다. LIFF를 활용하면 LINE 앱이 웹뷰를 관리해 주고 LINE 로그인 정보(userId, displayName 등)까지 자동으로 넘겨 줍니다. 별도 인증 서버 없이 LINE 사용자를 식별할 수 있습니다. Android에서 웹뷰를 사용해 웹 페이지를 앱 안에서 표시하는 것과 유사한 개념으로 이해하시면 됩니다.
그룹 영상 통화 서비스 전체 구조는 다음과 같습니다. 이 구조에서 3번 웹 앱 레이어(Web App Layer)와, 4번 서버 레이어(Server Layer)의 앱 서버(App Server)는 직접 구축해야 합니다. 웹 앱 레이어는 아래 ‘개발’ 섹션에서 함께 살펴볼 예정이며, 앱 서버는 이 글에서 직접 다루지는 않고 참고 자료로 대체할 예정입니다. 추후 다시 자세히 설명하겠습니다.

시퀀스 다이어그램은 다음과 같습니다.

개발 시작 전 준비 사항
개발을 시작하기 전에 몇 가지 준비해야 할 사항이 있습니다.
직접 준비해야 하는 사항
개발 환경 확인
이 글의 예제 코드는 Node.js 20 LTS 이상 환경에서 npm을 사용하는 것을 기준으로 작성했습니다. 또한 LIFF 특성에 따라 HTTPS 배포 환경이 필요하며, 로컬에서 개발한다면 ngrok으로 대체할 수 있습니다.
LINE Developers Console 및 LINE OA에서 필요한 사항 준비
LINE OA와 LIFF를 조합해 사용하려면 LINE Developers Console과 LINE OA Manager에서 몇 가지 사항을 준비해야 합니다(이 글에서는 이 과정을 간략히 소개하며, 자세한 사항은 LINE Developers 문서를 참고하시기 바랍니다).
먼저 LINE Developers Console에 로그인하기 위해 Business ID를 준비하고, 로그인 후 개발자 계정을 등록해서 이번 작업에 사용할 Provider를 생성합니다.
이후 LINE OA를 생성하고, LINE OA Manager에서 Messaging API 사용을 활성화합니다. 이때 앞 서 생성한 Provider를 선택합니다. Messaging API를 활성화하면 해당 Provider 아래에 LINE OA와 연결된 Messaging API 채널이 생성됩니다.
다음으로 같은 Provider에서 LINE Login 채널을 생성한 후, 해당 채널의 LIFF 탭에서 다음과 같이 앱을 등록합니다.
| 항목 | 값 |
|---|---|
| LIFF app name | LIFF Call |
| Size | Full |
| Endpoint URL | https://your-app.example.com(임시값으로 웹 배포 완료 후 실제 URL로 업데이트 필요) |
| Scope | profile, openid |
| Share Target Picker | ON |
위 과정에서 아래 두 가지 사항에 주의해야 합니다.
- 발급받은 LIFF ID(예: 1234567890-abcdefgh)는 이후 모든 초기화에 사용되므로 반드시 메모해 두세요.
- 이후 개발 단계에서 LINE 친구 초대(
shareTargetPickerAPI)를 작동하려면 LINE Login 채널이 Published 상태여야 합니다.
LINE Planet 팀에 요청해야 할 사항
다음으로 LINE Planet Console 계정 및 서비스 ID가 필요합니다. 이 두 가지는 LINE Planet 팀(dl_planet_help@linecorp.com)에 요청해 받을 수 있습니다.
개발
사전 준비가 끝나면 본격적으로 웹 앱 레이어 개발을 시작합니다. 참고로 이 글에서 전체 화면 구현 코드를 모두 다루지는 않 습니다. LIFF에서 LINE Planet SDK를 이용해 그룹 영상 통화 웹 앱을 만들 때 꼭 확인해야 하는 핵심 흐름과 주의할 점을 중심으로 코드를 제시하고 설명합니다. 통화 셋업, 미리보기, 통화 화면 UI를 포함한 기타 상세 사항은 직접 구성해야 합니다.
또한 첨부한 코드는 데모 목적으로 작성한 것입니다. 실제 프로덕션에 적용할 때에는 보안 및 에러 처리, 성능 최적화를 추가로 검토해야 합니다.
1단계: 통화 셋업 시 필요한 방 ID 설계 및 생성하기
일반적인 설정 과정에서는 사용자 ID 및 기타 정보를 앱 서버에 별도로 등록하는 단계가 필요합니다. 반면 LIFF 앱은 통화 전 설정에 필요한 정보를 LIFF에서 수집해서 이를 앱 내부에서 앱 서버에 등록할 수 있습니다. 따라서 사전 셋업 과정을 간소화할 수 있고, 사용자는 통화 전 별도 설정 없이 방 ID를 입력하는 것만으로 통화가 가능해집니다.
통화방을 만들고 참가할 때 사용할 방 ID는 다양한 방식으로 설계할 수 있습니다. 이 데모에서는 다음 코드와 같이 랜덤 문자열을 사용했지만, 서비스 목적에 따라 관심사 기반의 고정 방을 제공하거나 사용자 그룹별 방을 자동 생성하는 방식으로 확장할 수 있습니다.
// 16자리 영숫자 방 ID 생성 예시
const generateRoomId = (): string =>
crypto.randomUUID().replace(/-/g, '').slice(0, 16);
// 초대 링크로 들어온 경우 query string에서 roomId 복원
const params = new URLSearchParams(window.location.search);
const roomId = params.get('roomId') ?? generateRoomId();
2단계: 미리보기 화면 구현하기
미리보기 화면은 통화방에 들어가기 전 카메라와 마이크 상태를 미리 확인하는 화면입니다.
미리보기 페이지 핵심 구현
일반적인 웹 미디어 구현에서는 getUserMedia를 사용하지만 이 예제에서는 PlanetKit SDK의 MediaStreamManager(이하 MSM)로 다룹니다. MSM 인스턴스 하나가 미리보기에서 그룹 통화(conference)까지 그대로 이어지므로, 페이지 전환 시 카메라 및 마이크 권한을 재요청하지 않습니다. 또한 마이크 토글(온/오프)은 기존 오디오 트랙의 enabled 플래그만 조정하기 때문에 모바일 웹뷰에서 권한 요청 프롬프트가 다시 노출되지 않습니다(코드 속 useIsMobileDevice와 resolveFacingModeDeviceId는 다음 섹션에서 별도로 설명합니다).
import { useEffect, useRef, useState } from 'react';
import * as PlanetKit from '@line/planet-kit';
import { useIsMobileDevice } from '../hooks/useIsMobileDevice';
import { resolveFacingModeDeviceId } from '../utils/resolveFacingModeDeviceId';
export default function Preview({ onEnter }: { onEnter: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const msmRef = useRef<PlanetKit.MediaStreamManager | null>(null);
const [isReady, setIsReady] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(true);
const [isMicOn, setIsMicOn] = useState(true);
const [facingMode, setFacingMode] = useState<'front' | 'back'>('front');
const isMobileDevice = useIsMobileDevice();
// 마운트 시 MediaStreamManager 1회 생성
useEffect(() => {
msmRef.current = new PlanetKit.MediaStreamManager();
setIsReady(true);
}, []);
// 비디오 on / 카메라 전환 시 MSM을 통해 스트림 갱신
useEffect(() => {
if (!isReady || !isVideoOn) return;
const msm = msmRef.current!;
(async () => {
const videoInputDeviceId = isMobileDevice
? await resolveFacingModeDeviceId(facingMode)
: undefined;
// 스트림이 이미 있으면 비디오 트랙만 교체, 아니면 새로 생성
const stream = msm.hasVideoStream()
? await msm.changeVideoInputDevice(videoInputDeviceId!)
: await msm.createMediaStream({
videoInputDeviceId,
videoElement: videoRef.current ?? undefined,
});
if (videoRef.current) videoRef.current.srcObject = stream;
})();
}, [isReady, isVideoOn, facingMode, isMobileDevice]);
// 마이크 토글은 트랙 enabled만 조정 (권한 재요청 방지)
useEffect(() => {
const stream = msmRef.current?.getMediaStream();
stream?.getAudioTracks().forEach((t) => (t.enabled = isMicOn));
}, [isMicOn]);
const flipCamera = () =>
setFacingMode((f) => (f === 'front' ? 'back' : 'front'));
return (
<div className="preview">
<video ref={videoRef} autoPlay playsInline muted />
<div className="controls">
<button onClick={() => setIsVideoOn((v) => !v)}>
{isVideoOn ? '카메라 끄기' : '카메라 켜기'}
</button>
<button onClick={() => setIsMicOn((m) => !m)}>
{isMicOn ? '마이크 끄기' : '마이크 켜기'}
</button>
{/* 모바일 디바이스에서만 전/후면 전환 버튼을 노출 */}
{isMobileDevice && (
<button onClick={flipCamera} disabled={!isVideoOn}>
전/후면 전환
</button>
)}
<button onClick={onEnter}>입장하기</button>
</div>
</div>
);
}
모바일 기기에서만 전/후면 카메라 전환 버튼 노출하기
모바일에서는 전/후면 카메라 전환 버튼도 함께 제공합니다. 이때 뷰포트(viewport) 크기로 모바일 기기 여부를 판별하면 데스크톱의 좁은 창에서도 모바일 기기에서만 유효한 전/후면 전환 버튼이 노출돼 버립니다. 따라서 뷰포트가 아니라 User Agent(이하 UA)를 기반으로 모바일 기기 여부를 감지합니다.
import { useState, useEffect } from 'react';
export function useIsMobileDevice() {
const [isMobileDevice, setIsMobileDevice] = useState(false);
useEffect(() => {
const ua = navigator.userAgent;
setIsMobileDevice(/Android|iPhone|iPad|iPod/i.test(ua));
}, []);
return isMobileDevice;
}
전/후면(facingMode)에 맞는 카메라 deviceId 찾기
PlanetKit SDK의 MediaStreamManager는 facingMode 제약 옵션을 받지 않고 videoInputDeviceId만 받기 때문에 enumerateDevices()의 라벨을 매칭해 deviceId를 역산합니다. 라벨은 카메라 권한이 허용된 이후에만 채워지므로, 최초 호출 시에는 undefined를 돌려주고 SDK 기본 카메라(보통 전면)에 맡깁니다.
export async function resolveFacingModeDeviceId(
facingMode: 'front' | 'back'
): Promise<string | undefined> {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
const backPattern = /back|rear|environment/i;
const frontPattern = /front|user|facetime/i;
// back을 먼저 매칭 — "back user facing" 같은 라벨이 front로 오인되는 것을 방지
const matched =
facingMode === 'back'
? videoInputs.find((d) => backPattern.test(d.label))
: videoInputs.find(
(d) => !backPattern.test(d.label) && frontPattern.test(d.label)
);
return matched?.deviceId;
}
3단계: LINE Planet SDK 연동하기
여기서부터가 핵심입니다. LINE Planet SDK가 WebRTC를 추상화하여 제공하므로, 개발자는 미디어 처리나 네트워크 트래버설(network address translation traversal)을 직접 구현하지 않아도 됩니다.
이 단계는 다시 다음 세 단계로 나뉩니다.
- LIFF에서 사용자 정보 획득
- 앱 서버에서 액세스 토큰 발급
- LINE Planet SDK로 그룹 통화 참가
LIFF에서 사용자 정보 획득
LIFF SDK를 초기화하면 LINE 사용자 식별자(userId)와 표시 이름(displayName)을 얻을 수 있습니다. 이 두 값은 PlanetKit 통화 참가 시 myId와 displayName으로 그대로 사용합니다.
import liff from '@line/liff';
interface LiffUser {
userId: string;
displayName: string;
}
export async function initializeAndLogin(liffId: string): Promise<LiffUser | null> {
await liff.init({ liffId });
// LINE 앱이 아닌 외부 브라우저에서 들어온 경우 LINE 로그인으로 리다이렉트
if (!liff.isLoggedIn()) {
liff.login();
return null; // 리다이렉트 직후이므로 호출자는 다시 시도
}
const profile = await liff.getProfile();
return {
userId: profile.userId,
displayName: profile.displayName,
};
}
앱 서버에서 액세스 토큰 발급
LINE Planet 통화에 참가하려면 액세스 토큰이 필요합니다. 이 토큰은 앱 서버를 통해 발급받습니다.
앱 서버는 직접 구축해야 합니다. Firebase Cloud Functions를 활용하면 서버 인프라 설정 없이 빠르게 구성할 수 있습니다. 자세한 내용은 LINE Planet Documentation 사이트의 Firebase로 앱 서버 구축하기 블로그 글과 Firebase 공식 가이드를 참고하세요.
다음은 클라이언트에서 액세스 토큰을 획득하는 흐름 예시입니다.
// 앱 서버에서 액세스 토큰을 발급받는 예시 흐름
// 실제 구현은 앱 서버 구축 방식에 따라 달라집니다.
const getAccessToken = async (userId: string, serviceId: string) => {
// 앱 서버에 액세스 토큰 요청
const response = await fetch('/api/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, serviceId })
});
const { accessToken } = await response.json();
return accessToken;
};
LINE Planet SDK를 이용해 그룹 통화 참가
사용자 정보와 액세스 토큰이 준비되면 그룹 통화(Conference) 인스턴스를 만들고 joinConference를 호출합니다. 이때 2단계에서 만든 MediaStreamManager를 그대로 넘기면, 권한 재요청 없이 미리보기 화면에서 사용하던 카메라/마이크 스트림이 통화로 그대로 이어집니다.
import * as PlanetKit from '@line/planet-kit';
interface JoinParams {
roomId: string;
serviceId: string; // LINE Planet Console에서 발급
user: { userId: string; displayName: string };
accessToken: string;
mediaStreamManager: PlanetKit.MediaStreamManager;
myVideoElement: HTMLVideoElement;
roomAudioElement: HTMLAudioElement;
}
export async function joinConference(p: JoinParams) {
const conference = new PlanetKit.Conference({ logLevel: 'info' });
await conference.joinConference({
roomId: p.roomId,
myId: p.user.userId,
displayName: p.user.displayName,
myServiceId: p.serviceId,
roomServiceId: p.serviceId,
accessToken: p.accessToken,
mediaType: 'audiovideo',
mediaStreamManager: p.mediaStreamManager,
mediaHtmlElement: {
myVideo: p.myVideoElement,
roomAudio: p.roomAudioElement,
},
delegate: {
evtConnected: () => console.log('[Conference] connected'),
evtDisconnected: (reason) => console.log('[Conference] disconnected', reason),
evtPeerListUpdated: (peers) => console.log('[Conference] peers updated', peers),
// ... 필요한 이벤트 핸들러를 추가
},
});
return conference;
}
호출 측 흐름은 다음과 같이 단순해집니다.
// 1) LIFF로 사용자 식별
const user = await initializeAndLogin(LIFF_ID);
if (!user) return; // 로그인 리다이렉트 중
// 2) 앱 서버에 토큰 요청 (Authorization 헤더로 LINE 인증값을 전달)
const accessToken = await getAccessToken(user.userId, PLANET_SERVICE_ID);
// 3) PlanetKit 통화 입장
const conference = await joinConference({
roomId,
serviceId: PLANET_SERVICE_ID,
user,
accessToken,
mediaStreamManager: msm, // 2단계에서 만든 인스턴스
myVideoElement: myVideoRef.current!,
roomAudioElement: roomAudioRef.current!,
});
delegate에 등록한 콜백을 통해 통화 중 발생하는 이벤트를 받을 수 있습니다. 위 예시에는 이벤트를 evtConnected(입장 완료), evtDisconnected(종료), evtPeerListUpdated(다른 참가자가 입장하거나 퇴장했을 때 호출) 세 가지만 두었지만, ConferenceDelegate에는 마이크/카메라 상태 변경이나 발언 상태 변경 등의 다양한 이벤트가 정의되어 있으니 서비스에 필요한 것을 골라 등록하면 됩니다. 특히 evtPeerListUpdated는 다음 4단계에서 그리드를 동적으로 구성할 때에도 활용합니다.
4단계: 통화 화면을 동적 그리드로 구성하기
그룹 통화에서는 누군가가 입장하거나 나갈 때마다 그리드 셀 수가 달라집니다. WebPlanetKit은 이런 변화를 콜백으로 알려주므로 이벤트가 올 때마다 레이아웃과 영상 해상도를 함께 조정하면 됩니다. 3단계에서 joinConference 호출 시 등록했던 evtPeerListUpdated 콜백이 이 역할을 맡습니다. 참가자 목록이 바뀔 때마다 호출되므로, 여기서 현재 참가자 수를 기준으로 그리드를 재배치하고 각 참가자의 영상 해상도를 다시 요청하는 것이 일반적인 패턴입니다.
모바일은 화면이 좁아 한 번에 많은 셀을 띄우기 어려운 만큼 일정 인원을 넘으면 최근 발언자를 우선 표시하는 방식이 보기에도 편합니다. 예를 들어 다음과 같이 구성할 수 있습니다.
| 참가자 수 | 레이아웃 예시 | 영상 해상도 |
|---|---|---|
| 1명 | 전체 스크린 | N/A |
| 2명 | 2분할 | VGA |
| 3명 | 상단 2명, 하단 1명은 전체 너비로 표시 | VGA |
| 4명 | 2×2 그리드 | VGA |
| 5명 이상 | 2×2 그리드 유지(최근 발언자 2명, 본인, 앞서 표시한 3명 제외한 나머지 참여 인원 정보) | VGA |
셀 크기에 맞춰 해상도를 낮추면 대역폭과 디코딩 부담을 함께 줄일 수 있습니다(권장 해상도와 자세한 사용법은 WebPlanetKit 공식 문서를 참고하세요). 참고로 위 표는 일반적인 화상 회의를 가정한 예시이며, 서비스의 성격에 맞춰 UI를 특색 있게 구성할 수 있습니다.
5단계: LINE 친구 초대 기능 사용하기
다른 참여자가 바로 이 방으로 진입할 수 있도록 초대를 보낼 수 있습니다. 초대 링크를 클릭하면 LIFF 앱이 열리면서 해당 방 ID로 자동 참가됩니다. 이때 shareTargetPicker가 작동하려면 1단계에서 생성한 채널이 Published 상태여야 합니다.
다음은 통화하기 위해 생성한 방에 LINE 친구를 초대하는 기능을 사용하는 코드입니다.
const inviteFriends = async (roomId: string, liffId: string, displayName: string) => {
// ShareTargetPicker API 사용 가능 여부 확인
if (!liff.isApiAvailable('shareTargetPicker')) {
alert('이 환경에서는 친구 초대 기능을 사용할 수 없습니다.');
return;
}
// LIFF URL 생성 (roomId 포함)
const shareUrl = `https://liff.line.me/${liffId}?roomId=${encodeURIComponent(roomId)}`;
const shareMessage = `🎥 ${displayName}님이 화상 통화에 초대했습니다!\n\n룸: ${roomId}\n\n링크를 눌러 참여하세요:\n${shareUrl}`;
// ShareTargetPicker 실행
const result = await liff.shareTargetPicker(
[{ type: 'text', text: shareMessage }],
{ isMultiple: true } // 여러 명에게 동시 전송 허용
);
if (result) {
console.log('초대 메시지 전송 완료');
} else {
console.log('사용자가 취소함');
}
};
주요 PlanetKit API 소개
앞선 예제에서 사용한 PlanetKit API와, 통화 UI를 확장할 때 자주 사용하는 API를 간단히 정리해 소개하겠습니다. 더욱 자세한 사항은 LINE Planet Documentation 사이트를 참고해 주세요.
미디어 제어 API
통화 중 자주 사용하는 미디어 제어 API는 다음과 같습니다. 참고로 아래 예제의 conference는 3단계에서 new PlanetKit.Conference()로 생성하고 joinConference()로 입장한 인스턴스입니다.
- 오디오 음소거/해제
await conference.muteMyAudio(true); // 음소거
await conference.muteMyAudio(false); // 음소거 해제
- 비디오 일시정지/재개
await conference.pauseMyVideo(); // 비디오 끄기
await conference.resumeMyVideo(); // 비디오 켜기
- 상대방 비디오 요청(그리드 뷰에서 사용): 참여자가 늘어날수록 한 셀당 차지하는 픽셀이 작아지므로, 그리드 셀 크기에 맞춰 resolution을 낮춰 호출하면 대역폭을 절약할 수 있습니다.
// 예) 1:1은 'hd', 2x2는 'vga', 그 이상은 'qvga'
await conference.requestPeerVideo({
userId: peerId,
resolution: 'hd',
videoViewElement: peerVideoElement
});
- 통화 종료:
leaveConference는 동기 메서드입니다(void반환). 다른 미디어 제어 API와 달리await을 붙이지 않습니다.
conference.leaveConference();
가상 배경 API
MediaPipe 기반 의 배경 블러 기능입니다. 모바일 웹뷰에서는 지원되지 않으므로, 데스크톱 브라우저 혹은 데스크톱 LINE 환경에서만 노출하기를 권장합니다.
다음은 가상 배경을 초기화하는 예제 코드입니다.
import type VirtualBackground from '@line/planet-kit-virtual-background';
// VirtualBackground 싱글톤 인스턴스 (PlanetKitService 내부)
private static virtualBackgroundInstance: VirtualBackground
| null = null;
// 싱글톤 인스턴스 획득
public static async getVirtualBackgroundInstance(): Promise<VirtualBackground> {
if (!this.virtualBackgroundInstance) {
// 동적 임포트로 VirtualBackground 모듈 로드
const VirtualBackgroundModule = await import('@line/planet-kit-virtual-background');
const VirtualBackground = VirtualBackgroundModule.default;
// 싱글톤 인스턴스 생성 (MediaPipe 리소스 경로 지정)
this.virtualBackgroundInstance = new VirtualBackground({
locateFile: '/mediapipe-resource'
});
}
return this.virtualBackgroundInstance;
}
// MediaStreamManager 또는 Conference에 가상 배경 초기화
// target: 'msm' (미리보기용) 또는 'conference' (통화 중)
public async initializeVirtualBackground(target: 'msm' | 'conference'): Promise<void> {
const vbInstance = await PlanetKitService.getVirtualBackgroundInstance();
if (target === 'msm') {
await this.mediaStreamManager.registerVirtualBackground(vbInstance);
await this.mediaStreamManager.waitForVirtualBackgroundInitialization();
} else {
await this.conference.registerVirtualBackground(vbInstance);
await this.conference.waitForVirtualBackgroundInitialization();
}
}
다음은 가상 배경 블러 활성화 혹은 비활성화하는 예제 코드입니다.
// 배경 블러 활성화 (target: 'msm' 또는 'conference')
public async enableVirtualBackgroundBlur(
target: 'msm' | 'conference',
canvasElement?: HTMLCanvasElement,
blurRadius: number = 10
): Promise<boolean> {
try {
if (target === 'msm') {
await this.mediaStreamManager.startVirtualBackgroundBlur(canvasElement, blurRadius);
} else {
await this.conference.startVirtualBackgroundBlur(canvasElement, blurRadius);
}
return true;
} catch (error) {
console.warn('가상 배경 활성화 실패:', error);
return false; // UI에서 graceful fallback 처리
}
}
// 배경 블러 비활성화
public async disableVirtualBackground(target: 'msm' | 'conference'): Promise<boolean> {
try {
if (target === 'msm') {
await this.mediaStreamManager.stopVirtualBackground();
} else {
await this.conference.stopVirtualBackground();
}
return true;
} catch (error) {
console.warn('가상 배경 비활성화 실패:', error);
return false;
}
}
다음은 가상 배경 사용 예시입니다.
const handleVBToggle = async () => {
if (virtualBGEnabled) {
await planetKitService.disableVirtualBackground('msm');
} else {
const success = await planetKitService.enableVirtualBackgroundBlur('msm', canvasRef.current, 15);
if (!success) {
console.warn('VB 활성화 실패, VB 없이 진행');
}
}
};
미디어 스트림 관리 API
미리보기 화면에서 생성한 MediaStreamManager를 그룹 통화 화면에서 재사용하기 위해, 싱글톤 패턴으로 인스턴스를 보존합니다. PlanetKit SDK의 MediaStreamManager는 미디어 스트림 생성, 디바이스 변경, 가상 배경 처리를 통합 관리합니다. 이 인스턴스를 Conference에 전달하면 SDK가 기존 스트림을 자동으로 재사용합니다.
구현 패턴은 다음과 같습니다.
// 싱글톤 서비스 예시 (앱 전역에서 하나의 인스턴스 유지)
class MediaService {
private mediaStreamManager: MediaStreamManager | null = null;
async initMediaStreamManager() {
if (!this.mediaStreamManager) {
this.mediaStreamManager = new PlanetKit.MediaStreamManager();
}
return this.mediaStreamManager;
}
getMediaStreamManager() {
return this.mediaStreamManager;
}
releaseMediaStream() {
if (this.mediaStreamManager) {
this.mediaStreamManager.releaseMediaStream();
this.mediaStreamManager = null;
}
}
}
const mediaService = new MediaService(); // 싱글톤
페이지별 사용 흐름은 다음과 같습니다.
// 1. Preview 페이지: MediaStreamManager 초기화 및 스트림 생성
const msm = await mediaService.initMediaStreamManager();
await msm.createMediaStream({
audioInputDeviceId: selectedMic,
videoInputDeviceId: selectedCamera,
videoElement: videoRef.current
});
// 2. Conference 페이지: 동일한 MediaStreamManager 전달
const msm = mediaService.getMediaStreamManager();
await conference.joinConference({
// ... 기타 파라미터
mediaStreamManager: msm, // SDK가 기존 스트림 자동 사용
micOn: true,
cameraOn: true
});
// 3. 통화 종료 시 정리
mediaService.releaseMediaStream();
주요 LIFF 연동 API 소개
다음은 LIFF SDK를 초기화하고 LINE 로그인 정보를 획득하는 API 사용 예제 코드입니다.
import liff from '@line/liff';
const initializeLiff = async (liffId: string) => {
// LIFF SDK 초기화
await liff.init({ liffId });
// LINE 앱 내 실행 여부 확인
const isInClient = liff.isInClient();
// 언어 자동 감지
const userLanguage = liff.getLanguage();
// 로그인 상태 확인 및 프로필 획득
if (liff.isLoggedIn()) {
const profile = await liff.getProfile();
// profile 객체에는 다음 정보가 포함됩니다:
// - userId: LINE 사용자 고유 ID
// - displayName: 사용자 표시 이름
// - pictureUrl: 프로필 사진 URL
// - statusMessage: 상태 메시지
return profile;
}
return null;
};
트러블슈팅
Q. LIFF가 로컬 환경에서 작동하지 않습니다.
A. LIFF는 HTTPS에서만 작동합니다. 로컬 환경에서는 ngrok으로 로컬 서버를 터널링하면 해결됩니다.
Q. LINE Planet 통화 연결 시 CORS 오류가 발생합니다.
A. LINE Planet Console(Project>Project Settings>Configuration)에서 해당 도메인을 CORS 허용 목록에 등록해야 합니다.

Q. 로컬에서 앱 서버와 통신 시 CORS 문제가 생깁니다.
A. 빌드 도구의 dev server 프록시 기능(예: Vite의 server.proxy, Next.js의 rewrites)을 사용해 개발 환경에서만 앱 서버로 요청을 프록시할 수 있습니다.
Q. 모바일에서 카메라 전환이 일부 기기에서 실패합니다.
A. 일부 기기는 카메라 라벨에 ‘front’나 ‘back’ 키워드가 포함되지 않아 enumerateDevices() 라벨 매칭에 실패할 수 있습니다. 이 경우 resolveFacingModeDeviceId가 undefined를 반환하도록 두고 SDK 기본 카메라로 폴백시키거나, ‘back user facing’ 같은 라벨이 ‘front’로 오인되지 않도록 ‘back’ 패턴을 먼저 매칭하세요(2단계 코드 참고).
마치며
이 프로젝트는 PM과 Android 엔지니어, 단둘이서 웹 엔지니어 없이 진행했습니다. 이런 작업이 가능했던 이유는 두 가지였습니다. 첫째, LIFF와 LINE Planet이 각자 가장 어려운 부분을 이미 처리해 준다는 점입니다. LINE 인증, WebRTC 미디어 처리, 글로벌 네트워크 인프라를 직접 구현했다면 훨씬 오래 걸렸을 것입니다. 둘째, TypeScript와 React의 컴포넌트 모델이 Android의 View/ViewModel 패턴과 생각보다 유사하다는 점이었습니다.
LINE 생태계를 기반으로 실시간 통화 기능을 서비스에 추가하려는 팀에게 이 글이 좋은 출발점이 되길 바라며 이만 마칩니다. LINE Planet 도입 관련 문의가 있다면 dl_planet_help@linecorp.com으로 연락 주시기 바랍니다.