본격적인 이야기를 시작하기 전에, SDK(Software Development Kit)가 무엇인지 짚고 넘어가겠습니다.
라이브러리와 SDK는 모두 재사용 가능한 코드를 제공한다는 점에서 비슷해 보이지만, 제공하는 가치의 범위에서 차이가 있습니다.
라이브러리는 특정 기능을 수행하는 함수나 클래스의 모음이며 개발자는 필요한 부분만 가져다 쓰면 됩니다.
반면 SDK는 하나의 서비스나 플랫폼과 상호작용하기 위한 종합적인 도구 세트입니다.
API 클라이언트, 타입 정의, 문서, 샘플 코드, 때로는 CLI 도구까지 포함합니다.
라이브러리는 도구 하나, SDK는 도구 상자
예를 들어, Axios는 HTTP 요청을 쉽게 만들어주는 라이브러리입니다.
반면 AWS SDK는?
등을 모두 포함한 SDK입니다.
제가 만들고자 했던 것은 단순히 원격 지원 기능을 제공하는 라이브러리가 아니라, 다양한 환경에서 우리 서비스를 통합할 SDK였습니다.
저는 RemoteVS라는 비대면 상담 서비스를 개발하고 유지보수하고 있습니다.
상담이 필요한 고객이 영업점에 방문하지 않고도, 상담원과 실시간으로 소통하며 업무를 처리할 수 있는 서비스죠.
상담이 시작되면 다음과 같은 기능들이 제공됩니다
이런 복잡한 기능들이 하나의 React 애플리케이션 안에 모두 구현되어 있었습니다.
처음 RemoteVS는 React 기반의 SPA로 시작했습니다.
빠른 시장 출시를 위해 3개월이라는 타이트한 일정 안에 개발해야 했고, 자연스럽게 모든 기능이 React 컴포넌트 레벨까지 깊숙이 결합되었습니다.
문제는 금융기관에 납품하면서 본격화되었습니다.
첫 번째 금융기관은 React 환경이었지만, 두 번째 금융기관은 JSP 환경이었습니다.
심지어는 JSP 개발자가 따로 있는 상태였고 저희 서비스의 기능을 제공해 줘야 되는 입장이었습니다.
우리 제품은 React에 강하게 결합되어 있었고, 다른 환경에서는 사용할 수 없었습니다.
빠른 개발의 대가는 명확했습니다
결국 "독립적인 SDK를 만들자"는 결론에 도달했습니다.
하지만 당시 저는 3년차 주니어 개발자였고, SDK를 설계해본 경험이 전무했습니다.
혼자서는 어려웠습니다.
주변 개발자분들께 조언을 구했고 그 조언들은 제 설계의 토대가 되었습니다.
첫 번째 조언은 명확했습니다.
React에 의존하는 UI 코드와 실제 비즈니스 로직을 완전히 분리해야 한다는 것이었습니다.
React는 그냥 UI를 그리는 도구일 뿐입니다.
핵심 로직은 React를 몰라도 동작해야 합니다.
이 조언은 제게 큰 영감을 주었습니다.
WebRTC 연결, 실시간 메시지 전송, 미디어 관리 같은 핵심 기능은 React와 무관하게 동작할 수 있어야 했습니다.
두 번째 조언은 레이어 아키텍처에 관한 것이었습니다.
상위 레이어는 하위 레이어만 의존해야 합니다.
하위 레이어가 상위를 알면 안 됩니다.
이 원칙은 제 설계의 기둥이 되었습니다.
인프라 레이어는 비즈니스 로직을 몰라야 하고, 비즈니스 레이어는 UI를 몰라야 했습니다.

세 번째 조언은 가장 실용적이었습니다.
구현하기 전에 인터페이스부터 정하세요.
어떻게 사용될지 상상하면서요.
이 접근법은 제게 큰 도움이 되었습니다.
"어떻게 구현할까?"가 아니라 "어떻게 사용될까?"를 먼저 고민하니, 불필요한 기능을 만들지 않게 되었습니다.
네 번째 조언은 에러 처리에 관한 것이었습니다.
SDK는 예측 가능하게 실패해야 합니다.
사용자가 뭐가 잘못됐는지 알 수 있어야 해요.
금융권 특성상 문제가 생기면 원격 디버깅이 어렵습니다.
명확한 에러 메시지와 로깅이 필수적이었습니다.
SDK 설계에서 가장 많은 시간을 쏟은 부분은 인터페이스 네이밍이었습니다.
메서드 이름 하나가 SDK 사용 경험을 좌우하고, 더 나아가 제품의 도메인 모델을 코드로 표현하는 것이기 때문입니다.
처음에는 일반적인 네이밍을 고려했습니다
start() vs connect() vs initialize()stop() vs disconnect() vs destroy()하지만 이런 이름들은 너무 기술적이었습니다.
우리 제품의 핵심 개념을 제대로 담지 못했던 거 같아요.
이 구조를 보고 나니 답이 명확해졌습니다.
단순히 "연결"이 아니라 "방"이라는 도메인 개념을 메서드 이름에 담았습니다.
이렇게 하니 몇 가지 장점이 생겼습니다
미디어 제어 메서드도 고민이 많았습니다
toggleVideo() - 짧지만 상태를 명시할 수 없다enableVideo() / disableVideo() - 직관적이지만 메서드가 두 배로 늘어남updateVideo({ enabled: boolean }) - 확장 가능하지만 단순 on/off에는 과함setVideoEnabled(boolean) - 약간 길지만 의미가 가장 명확결국 setVideoEnabled(enabled: boolean) 형태를 선택했습니다.
조금 길더라도 의미가 명확한 것이 더 중요하다고 판단했습니다.
세션 종료에는 두 가지 레벨이 있었습니다
leaveRoom(): 방에서 나가지만 세션은 유지 (일시적 이탈)closeSession(): 세션을 완전히 종료 (영구적 종료)이런 구분은 나중에 "재접속" 기능을 구현할 때 필수적이었습니다.
에러 처리 전략도 많은 고민이 필요했습니다.
특히 비동기 작업이 많은 환경에서 어떻게 에러를 전달할 것인가가 핵심이었습니다.
Promise 기반 에러는 비교적 명확했습니다
하지만 비동기 이벤트 중 발생하는 에러는 더 복잡했습니다.
연결 후 갑자기 네트워크가 끊어지거나, 미디어 디바이스가 제거되는 경우를 어떻게 처리할 것인가?
처음 SDK를 만들 때는 에러 처리가 여기저기 흩어져 있었습니다.
컴포넌트마다 각자의 방식으로 에러를 처리하니, 일관성도 없고 디버깅도 어려웠습니다.
해결책은 중앙집중식 에러 이벤트 시스템이었습니다.
이제 SDK 내부 어디서든 에러가 발생하면 중앙 이벤트 에미터로 전달합니다.
외부에서는 하나의 채널로 모든 에러를 받습니다이 구조의 장점
하지만 이 방식에도 한계가 있었습니다.
전역 싱글톤이다 보니 여러 SDK 인스턴스를 사용할 때 문제가 생길 수 있었죠.
그래서 다음 버전에서는 각 SDK 인스턴스가 자체 EventEmitter를 가지도록 개선했습니다.
응집도는 높이고 결합도는 낮추는 것, 이론으로는 쉽지만 실제로는 정말 어려웠습니다.
잘못된 설계
처음에는 모든 기능을 하나의 거대한 클래스에 넣었습니다.
MediaManager, ConnectionManager, MessageHandler 같은 개념이 모두 SDK 안에 뒤섞여 있었죠.
개선된 설계
각 책임을 독립적인 매니저로 분리했습니다
이렇게 하니
하게 되었습니다
React에서는 useState, useEffect로 상태 변화를 처리했습니다.
하지만 SDK는 프레임워크에 독립적이어야 했습니다.
처음 SDK를 만들 때는 단순한 Callback 방식을 사용했습니다.
하지만 이 방식은 금방 문제가 드러났습니다
특히 금융기관마다 다른 요구사항이 생기면서 콜백이 계속 늘어났고, 결국 유지보수가 불가능해졌습니다.
다음 버전을 만들 때는 근본적인 해결책이 필요했습니다.
몇 가지 옵션을 고려했습니다
EventEmitter를 선택한 이유는 명확했습니다
이벤트를 설계하면서 지킨 원칙들
가장 하위 레이어는 실제 기술 구현을 담당합니다.
WebRTC, MQTT 같은 구체적인 기술은 모두 이 레이어에 숨겨집니다.
핵심은 추상화였습니다.
상위 레이어는 "어떤 기술을 쓰는지" 몰라도 되어야 했습니다.
예를 들어, 메시지 전송을 담당하는 Transport 계층
이렇게 하면 나중에 WebSocket이나 다른 전송 방식을 추가해도 상위 레이어는 변경할 필요가 없습니다.
중간 레이어는 순수한 비즈니스 로직만 담당합니다.
같은 도메인 로직이 여기 있습니다.
중요한 것은 이 레이어가 UI도, 구체적인 기술도 모른다는 점입니다.
순수한 TypeScript/JavaScript 로직만 있습니다.
가장 상위 레이어는 외부에 노출되는 공개 API입니다.
개발자가 직접 호출하는 메서드들이 여기 있습니다.
이 레이어의 핵심은 단순함입니다.
내부가 아무리 복잡해도, 사용자는 간단한 인터페이스만 보면 됩니다.
좋은 SDK는 코드만으로 완성되지 않습니다.
문서와 샘플이 SDK의 나머지 절반입니다.
README는 개발자가 가장 먼저 보는 문서입니다.
5분 안에 SDK의 핵심을 파악하고, 10분 안에 첫 코드를 실행할 수 있어야 합니다.
제가 따른 구조
TypeScript 타입 정의와 JSDoc 주석을 활용하면 IDE에서 자동완성과 함께 문서를 볼 수 있습니다.
연결 과정, 에러 처리 흐름 같은 복잡한 상호작용은 글로 설명하기 어렵습니다.
시퀀스 다이어그램이 훨씬 명확합니다.

가장 효과적인 문서는 동작하는 예제입니다.
데모 페이지를 만들어서
금융기관 담당자들이 데모 페이지를 보면 SDK가 어떻게 동작하는지 바로 이해할 수 있었습니다.
SDK를 만들었지만, 여전히 해결되지 않은 문제들이 있습니다.
가장 큰 문제는 각 고객사마다 요구사항이 다르다는 점입니다
현재는 이런 커스터마이징을 SDK 내부에 조건문으로 처리하고 있습니다
이 방식은 몇 가지 문제가 있습니다
현재는 버전 관리를 수동으로 하고 있습니다.
package.json의 version 필드를 직접 수정하고, Git 태그를 수동으로 붙입니다.
문제는 고객사마다 다른 버전을 쓴다는 점입니다
버그 수정이 발생하면 어느 버전부터 적용해야 할지, 각 고객사는 어느 버전으로 업데이트해야 할지 추적이 어렵습니다.
이 문제들을 해결하기 위한 다음 단계는 Core + Plugin 구조입니다.
Day.js의 Plugin시스템을 보고 영감을 받았습니다
이 구조의 장점
우리 SDK에도 이를 적용하면
Plugin 구조를 설계하면서 두 가지 방향을 고민했습니다
1. 기능별로 잘게 쪼개기 장점결국 기능별로 잘게 쪼개는 방식이 더 유연하고 확장 가능하다고 판단했습니다.
고객사별로 필요한 Plugin 조합만 다르게 가져가면 되니까요.
Plugin 구조를 위해 필요한 것들
beforeConnect: 연결 전afterConnect: 연결 후onMessage: 메시지 수신 시onError: 에러 발생 시semantic-release를 도입해서 버전 관리를 자동화할 계획입니다
커밋 메시지 컨벤션(Conventional Commits)을 따르면
feat: → minor 버전 증가 (1.0.0 → 1.1.0)fix: → patch 버전 증가 (1.0.0 → 1.0.1)BREAKING CHANGE: → major 버전 증가 (1.0.0 → 2.0.0)SDK의 안정성을 위해 체계적인 테스트 전략이 필요합니다.
테스트 피라미드가 아닌 테스트 트로피(Testing Trophy) 전략을 선택할 계획입니다.

테스트 피라미드는 단위 테스트를 많이, E2E 테스트를 적게 가져가라고 합니다.
하지만 SDK처럼 모듈 간 상호작용이 중요한 시스템에서는 단위 테스트만으로는 충분하지 않습니다.
테스트 트로피는
SDK에서는
같은 통합 테스트가 가장 중요합니다.
통합 테스트가 요구사항 변경으로 자주 깨지는 문제를 피하기 위해
처음에는 "모든 것을 추상화해야 한다"고 생각했습니다.
하지만 과도한 추상화는 오히려 코드를 복잡하게 만들었습니다.
배운 교훈
추상화는 반복이 3번 발생했을 때 고려하자
2번 반복까지는 중복을 허용하는 것이 더 나을 수 있다
SDK의 내부 구현은 얼마든지 바꿀 수 있지만, 공개 인터페이스는 한번 정하면 바꾸기 어렵습니다.
배운 교훈
인터페이스를 설계할 때는 신중하게, 구현할 때는 빠르게
아무리 좋은 SDK를 만들어도 문서가 없으면 아무도 쓰지 않습니다.
배운 교훈
문서화와 샘플 코드에 투자하는 시간이 아끼지 말자
명확한 에러 메시지 하나가 수십 통의 이메일을 줄여줍니다.
배운 교훈
"에러가 발생했습니다"가 아니라
"네트워크 연결을 확인하세요.(ERR_NETWORK_TIMEOUT)"처럼 구체적으로
시니어 개발자들의 조언이 가장 큰 도움이 되었습니다.
혼자 고민하며 시간을 낭비하지 말고, 경험 있는 사람들에게 물어보세요.
배운 교훈
"이렇게 설계하려는데 어떻게 생각하세요?"라는 질문 하나가 몇 주의 삽질을 줄여준다.
4년 차 개발자가 처음 SDK를 설계하면서 겪은 시행착오와 고민들을 공유해보았습니다.
완벽한 설계는 없는 거 같습니다.
지금도 개선할 점이 많고, 앞으로도 계속 발전시켜 나갈 것입니다.
하지만 처음부터 완벽을 추구했다면 아마 시작조차 못 했을 것입니다.
중요한 것은 시작하는 것, 그리고 계속 개선해나가는 것입니다.
비슷한 과제를 앞둔 개발자분들께 작은 도움이 되었으면 합니다 🙂
// 컴포넌트에 모든 것이 섞여 있음
function VideoCall() {
const [peerConnection, setPeerConnection] = useState<RTCPeerConnection>();
const [dataChannel, setDataChannel] = useState<RTCDataChannel>();
useEffect(() => {
// WebRTC 연결 로직이 컴포넌트 안에...
// MQTT 구독 로직도 컴포넌트 안에...
// 비즈니스 로직도 컴포넌트 안에...
}, []);
return <div>{/* UI */}</div>;
}
// 구현 전 인터페이스부터 정의
interface RemoteVSSDKContract {
joinRoom(): Promise<void>;
leaveRoom(): Promise<void>;
closeSession(): Promise<void>;
setVideoEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
// ... 기타 메서드
}
interface RemoteVSSDKContract {
createRoom(): Promise<void>; // 상담원: 방 생성
joinRoom(): Promise<void>; // 고객: 방 입장
leaveRoom(): Promise<void>; // 방 나가기
}
// ❌ 의미가 모호함
sdk.toggleVideo(); // 켜는 건가 끄는 건가?
// ✅ 의도가 명확함
sdk.setVideoEnabled(true); // 비디오 켜기
sdk.setVideoEnabled(false); // 비디오 끄기
interface RemoteVSSDKContract {
leaveRoom(): Promise<void>; // 방에서 나가기 (재입장 가능)
closeSession(): Promise<void>; // 세션 완전 종료 (재입장 불가)
}
try {
await sdk.joinRoom();
} catch (error) {
if (error instanceof NetworkError) {
// 네트워크 문제 처리
} else if (error instanceof PermissionError) {
// 권한 문제 처리
}
}
// 1. 외부에 노출할 에러 코드 정의
export enum RVS_ERROR_CODE {
/** 카메라 또는 마이크 권한 거부 */
ERROR_PERMISSION_DENIED = 40250,
/** 카메라 또는 마이크 장치를 찾을 수 없음 */
ERROR_DEVICE_NOT_FOUND = 40251,
/** 존재하지 않는 방에 접근할 경우 */
ROOM_NOT_FOUND = 40992,
/** 라이센스 초과 */
ERROR_OVER_LICENSE_COUNT = 49001,
/** WebRTC 연결 실패 */
WEBRTC_CONNECTION_FAILED = 50001
// ... 기타 에러 코드 추가
}
// 2. 전역 에러 이벤트 에미터 생성
export const rvsErrorEmitter = new EventEmitter<{
ERROR: (errorCode: RVS_ERROR_CODE) => void;
}>();
// SDK 내부: 미디어 권한 체크 중 에러 발생
public async checkMediaPermissions() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
let errorCode = RVS_ERROR_CODE.ERROR_MEDIA_DEVICE_UNKNOWN;
if (error instanceof DOMException) {
switch (error.name) {
case 'NotAllowedError':
errorCode = RVS_ERROR_CODE.ERROR_PERMISSION_DENIED;
break;
case 'NotFoundError':
errorCode = RVS_ERROR_CODE.ERROR_DEVICE_NOT_FOUND;
break;
}
}
// 중앙 에러 이벤트 에미터로 전달
rvsErrorEmitter.emit('ERROR', errorCode);
return false;
}
}
// 사용자 코드
rvsErrorEmitter.on("ERROR", (errorCode) => {
switch (errorCode) {
case RVS_ERROR_CODE.ERROR_PERMISSION_DENIED:
alert("카메라 권한을 허용해주세요");
break;
case RVS_ERROR_CODE.ROOM_NOT_FOUND:
alert("존재하지 않는 상담방입니다");
break;
case RVS_ERROR_CODE.ERROR_OVER_LICENSE_COUNT:
alert("현재 상담원이 모두 사용 중입니다");
break;
// ... 기타 에러 처리
}
});
// 개선된 버전: 인스턴스별 에러 이벤트
const sdk = new RemoteVSSDK(config);
sdk.on(RemoteVSSDK.Events.ERROR, (error) => {
console.error("에러 발생:", error.code, error.message);
});
// ❌ 나쁜 예: 하나의 클래스에 모든 책임
class RemoteVSSDK {
private setupMedia() {
/* ... */
}
private connectWebRTC() {
/* ... */
}
private sendMessage() {
/* ... */
}
private handleIncomingMessage() {
/* ... */
}
// ... 수백 줄의 코드
}
// ✅ 좋은 예: 책임별 분리
class MediaDeviceManager {
// 미디어 디바이스 관리만 담당
}
class ProtocolManager {
// 프로토콜 처리만 담당
}
class PushClientManager {
// Push 연결 관리만 담당
}
class RemoteVSSDK extends EventEmitter {
private readonly mediaDeviceManager: MediaDeviceManager;
private readonly protocolManager: ProtocolManager;
private readonly pushClientManager: PushClientManager;
constructor(config: RemoteVSSDKConfig) {
super();
this.mediaDeviceManager = new MediaDeviceManager();
this.protocolManager = new ProtocolManager();
this.pushClientManager = new PushClientManager();
}
}
// 초기 버전: Callback 방식
class RemoteVSSDK {
private handleConnected?: () => void;
private handleDisconnected?: () => void;
private handleReceiveMessage?: (message: object) => void;
private handleRVSError?: (errorCode: number) => void;
// ... 10개 이상의 콜백
set onConnected(handler: typeof this.handleConnected) {
this.handleConnected = handler;
}
set onDisconnected(handler: typeof this.handleDisconnected) {
this.handleDisconnected = handler;
}
// 내부에서 호출
private notifyDisconnected() {
if (this.handleDisconnected) {
this.handleDisconnected();
}
}
}
/**
* SDK 이벤트 이름 상수
*
* @description
* 네임스페이스 기반 이벤트 네이밍
* - local:* - 로컬(본인) 관련 이벤트
* - remote:* - 원격(상대방) 관련 이벤트
* - session:* - 세션 관련 이벤트
*/
export const SDKEvents = {
// Local (본인) 스트림
LOCAL_STREAM_READY: "local:stream:ready",
LOCAL_VIDEO_QUALITY_CHANGED: "local:video:quality:changed",
LOCAL_MEDIA_CHANGED: "local:media:changed",
// Remote (상대방) 스트림 및 상태
REMOTE_STREAM_READY: "remote:stream:ready",
REMOTE_LEAVE: "remote:leave",
REMOTE_MEDIA_CHANGED: "remote:media:changed"
// ... 기타 이벤트 추가
} as const;
class RemoteVSSDK extends EventEmitter {
static readonly Events = SDKEvents;
}
// 사용 예시
sdk.on(RemoteVSSDK.Events.REMOTE_LEAVE, () => {
console.log("상대방이 나갔습니다");
});
// 추상 베이스 클래스
abstract class BaseTransport {
public abstract send(message: unknown): void;
public abstract isConnected(): boolean;
}
// 구현체 1: WebRTC DataChannel
class DataChannelTransport extends BaseTransport {
public send(message: unknown) {
// DataChannel로 전송
}
public isConnected() {
// DataChannel 연결 상태 확인
}
}
// 구현체 2: MQTT
class MQTTTransport extends BaseTransport {
public send(message: unknown) {
// MQTT로 전송
}
public isConnected() {
// MQTT 연결 상태 확인
}
}
// Hybrid: DataChannel 우선, 실패 시 MQTT로 fallback
class HybridTransport extends BaseTransport {
constructor(
private dataChannelTransport: DataChannelTransport,
private mqttTransport: MQTTTransport
) {
super();
}
public send(message: unknown) {
// DataChannel 우선, 실패 시 MQTT
if (this.dataChannelTransport.isConnected()) {
this.dataChannelTransport.send(message);
} else {
this.mqttTransport.send(message);
}
}
public isConnected() {
return this.dataChannelTransport.isConnected() || this.mqttTransport.isConnected();
}
}
// 현재 구조의 한계
if (clientType === "CLIENT_A") {
enableSecurityLog();
enableReconnection();
}
if (clientType === "CLIENT_B") {
enableScreenCapture();
enableCustomAuth();
}
// ... 계속 추가됨
const sdk = new RemoteVSSDK({ accessCode: "123456" });
// 고객사 A: 필요한 기능만 조합
sdk.use(SecurityLogPlugin);
sdk.use(ScreenCapturePlugin);
// 고객사 B: 다른 조합
sdk.use(CustomAuthPlugin);
sdk.use(FileTransferPlugin);
await sdk.joinRoom();
sdk.use(SecurityLogPlugin); // 보안 로그
sdk.use(ScreenCapturePlugin); // 화면 캡처
sdk.use(ReconnectionPlugin); // 재접속
sdk.use(ClientAPlugin); // A사용 모든 기능
sdk.use(ClientBPlugin); // B사용 모든 기능
interface RemoteVSSDKPlugin {
name: string;
version: string;
install(sdk: RemoteVSSDK): void;
}
class RemoteVSSDK extends EventEmitter {
private plugins: RemoteVSSDKPlugin[] = [];
public use(plugin: RemoteVSSDKPlugin) {
plugin.install(this);
this.plugins.push(plugin);
return this; // chaining 지원
}
}
// ❌ 구현 세부사항에 의존
test("should call setupPeerConnection", () => {
sdk.setupPeerConnection(); // 내부 메서드
expect(peerConnection.createOffer).toHaveBeenCalled();
});
// ✅ 사용자 행동 중심
test("should connect when user calls joinRoom()", async () => {
await sdk.joinRoom();
await waitFor(() => {
expect(sdk.getConnectionState()).toBe("connected");
});
});
describe("연결 흐름", () => {
test("연결 요청 → 상태 업데이트 → 연결 완료", async () => {
// Given: SDK 초기화
const sdk = new RemoteVSSDK({ accessCode: "ABC123" });
// When: 연결 시도
const promise = sdk.joinRoom();
// Then: 상태가 순차적으로 변경됨
expect(sdk.getConnectionState()).toBe("connecting");
await promise;
expect(sdk.getConnectionState()).toBe("connected");
});
test("연결 실패 시 재시도 로직 동작", async () => {
// Given: 네트워크가 불안정한 상태
mockNetwork.unstable();
// When: 연결 시도
await sdk.joinRoom();
// Then: 자동 재시도 후 연결됨
expect(retryAttempts).toBeGreaterThan(1);
expect(sdk.getConnectionState()).toBe("connected");
});
});
// ❌ CSS 선택자에 의존
const button = screen.getByClassName("connect-button");
// ✅ Test ID 사용
const button = screen.getByTestId("connect-button");
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
# 현재 (수동)
vim package.json # version 수정
git commit -m "chore: bump version to 1.1.0"
git tag v1.1.0
git push --tags
# 목표 (자동)
git commit -m "feat: add screen capture"
# → CI/CD가 자동으로 1.1.0으로 버전 올리고 태그 생성
/**
* RemoteVS SDK
*
* @example
* ```typescript
* const sdk = new RemoteVSSDK({ accessCode: 'ABC123' });
*
* sdk.on(RemoteVSSDK.Events.REMOTE_LEAVE, () => {
* console.log("상대방이 나갔습니다");
* });
*
* await sdk.joinRoom();
* ```
*/
export class RemoteVSSDK extends EventEmitter {
/**
* 세션에 연결합니다
*
* @throws {NetworkError} 네트워크 연결 실패 시
* @throws {PermissionError} 미디어 권한이 없을 때
*/
async joinRoom(): Promise<void> {
// ...
}
}