<chan9yu />
홈포스트시리즈태그About


@chan9yu's dev blog

프론트엔드 개발의 아이디어와 경험을 기록하는 개발 블로그
코드와 디자인, 사용자 경험을 아우르는 인사이트를 담습니다.

RSSGitHubEmail
© 2026 chan9yu. All rights reserved.

강결합된 React 앱을 독립적인 SDK로 분리하기

컴포넌트 레벨까지 산재된 코어 로직을 독립적인 SDK로 추출하며 마주한 설계 고민과 의사결정 과정

2025년 12월 3일
 
SDK설계아키텍처인터페이스

강결합된 React 앱을 독립적인 SDK로 분리하기
이전 글

리액트를 까본 사람 손 🙋 (Virtual DOM부터 Fiber까지)

다음 글

2025년, 번아웃과 성장 사이에서

댓글

목차

  • SDK란 무엇인가
  • 왜 SDK를 만들게 되었나?
  • 이런 서비스를 만들고 있었습니다
  • 시작은 React 제품이었습니다
  • 기술 부채의 대가
  • 도움이 됐던 네 가지 조언
  • "코어를 독립적인 모듈로 분리하세요"
  • "레이어를 분리하세요"
  • "인터페이스를 먼저 설계하세요"
  • "에러 처리는 정말 중요합니다"
  • 수많은 고민들
  • 인터페이스 네이밍
  • 라이프사이클 메서드
  • 미디어 제어
  • 세션 관리
  • 에러 처리
  • 에러 중앙처리 시스템
  • 응집도와 결합도
  • 이벤트 모델
  • Callback 방식의 한계
  • 왜 EventEmitter를 선택했나
  • 이벤트 설계 원칙
  • 레이어 분리
  • Infrastructure Layer
  • Business Layer
  • SDK Layer
  • 문서화와 샘플
  • README.md
  • JSDoc
  • 시퀀스 다이어그램
  • 데모 페이지
  • 현재의 문제점
  • 납품별 커스터마이징의 한계
  • 버전 관리의 어려움
  • 향후 계획
  • Day.js에서 얻은 영감
  • 기능별 vs 고객사별 Plugin
  • 구현 계획
  • 배포 및 버전 관리 자동화
  • 테스트 전략
  • 왜 테스트 트로피인가?
  • 통합 테스트 설계 원칙
  • 배운 점
  • 추상화는 비용이다
  • 인터페이스가 구현보다 중요하다
  • 문서는 코드만큼 중요하다
  • 에러 메시지는 사용자와의 소통이다
  • 혼자 고민하지 말자
  • 마치며

SDK란 무엇인가

본격적인 이야기를 시작하기 전에, SDK(Software Development Kit)가 무엇인지 짚고 넘어가겠습니다.
라이브러리와 SDK는 모두 재사용 가능한 코드를 제공한다는 점에서 비슷해 보이지만, 제공하는 가치의 범위에서 차이가 있습니다.

라이브러리는 특정 기능을 수행하는 함수나 클래스의 모음이며 개발자는 필요한 부분만 가져다 쓰면 됩니다.
반면 SDK는 하나의 서비스나 플랫폼과 상호작용하기 위한 종합적인 도구 세트입니다.
API 클라이언트, 타입 정의, 문서, 샘플 코드, 때로는 CLI 도구까지 포함합니다.

라이브러리는 도구 하나, SDK는 도구 상자

예를 들어, Axios는 HTTP 요청을 쉽게 만들어주는 라이브러리입니다.
반면 AWS SDK는?

  • AWS 서비스와 통신하기 위한 클라이언트
  • 인증 처리
  • 타입 정의
  • 에러 처리

등을 모두 포함한 SDK입니다.

제가 만들고자 했던 것은 단순히 원격 지원 기능을 제공하는 라이브러리가 아니라, 다양한 환경에서 우리 서비스를 통합할 SDK였습니다.

왜 SDK를 만들게 되었나?

이런 서비스를 만들고 있었습니다

저는 RemoteVS라는 비대면 상담 서비스를 개발하고 유지보수하고 있습니다.
상담이 필요한 고객이 영업점에 방문하지 않고도, 상담원과 실시간으로 소통하며 업무를 처리할 수 있는 서비스죠.

상담이 시작되면 다음과 같은 기능들이 제공됩니다

  • 실시간 영상/음성 통화: 서로의 얼굴을 보며 대화
  • 미디어 제어: 카메라, 마이크 on/off
  • 화면 공유: 상담원이 고객에게 화면 시연
  • 문서 공유: 이미지, PDF 등을 실시간으로 공유
  • 협업 도구: 공유된 문서 위에 그리기, 레이저 포인터, 스포트라이트
  • 채팅: 텍스트 메시지 및 파일 전송

이런 복잡한 기능들이 하나의 React 애플리케이션 안에 모두 구현되어 있었습니다.

시작은 React 제품이었습니다

처음 RemoteVS는 React 기반의 SPA로 시작했습니다.
빠른 시장 출시를 위해 3개월이라는 타이트한 일정 안에 개발해야 했고, 자연스럽게 모든 기능이 React 컴포넌트 레벨까지 깊숙이 결합되었습니다.

문제는 금융기관에 납품하면서 본격화되었습니다.
첫 번째 금융기관은 React 환경이었지만, 두 번째 금융기관은 JSP 환경이었습니다.
심지어는 JSP 개발자가 따로 있는 상태였고 저희 서비스의 기능을 제공해 줘야 되는 입장이었습니다.

우리 제품은 React에 강하게 결합되어 있었고, 다른 환경에서는 사용할 수 없었습니다.

기술 부채의 대가

빠른 개발의 대가는 명확했습니다

  1. 코드가 산재: 핵심 로직이 여러 컴포넌트에 흩어져 있어 어디서부터 분리해야 할지 막막했습니다.
  2. 테스트 불가능: UI와 로직이 섞여 있어 단위 테스트를 작성할 수 없었습니다.
  3. 재사용 불가: 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()

하지만 이런 이름들은 너무 기술적이었습니다.
우리 제품의 핵심 개념을 제대로 담지 못했던 거 같아요.

우리 제품의 도메인 모델은 이렇습니다
  1. 백엔드에서 상담방(Room)이 먼저 생성됨
  2. 상담원이 방을 만들고 대기
  3. 고객이 접속 코드로 그 방에 입장
  4. 두 주체가 방 안에서 원격 상담 진행

이 구조를 보고 나니 답이 명확해졌습니다.

단순히 "연결"이 아니라 "방"이라는 도메인 개념을 메서드 이름에 담았습니다.
이렇게 하니 몇 가지 장점이 생겼습니다

  • 코드만 봐도 제품의 동작 방식을 유추 가능
  • 상담원과 고객의 역할 차이가 메서드 이름으로 명확히 구분됨
  • 신규 개발자가 온보딩할 때 도메인 이해가 빠름

미디어 제어

미디어 제어 메서드도 고민이 많았습니다

  • toggleVideo() - 짧지만 상태를 명시할 수 없다
  • enableVideo() / disableVideo() - 직관적이지만 메서드가 두 배로 늘어남
  • updateVideo({ enabled: boolean }) - 확장 가능하지만 단순 on/off에는 과함
  • setVideoEnabled(boolean) - 약간 길지만 의미가 가장 명확

결국 setVideoEnabled(enabled: boolean) 형태를 선택했습니다.
조금 길더라도 의미가 명확한 것이 더 중요하다고 판단했습니다.

세션 관리

세션 종료에는 두 가지 레벨이 있었습니다

  • leaveRoom(): 방에서 나가지만 세션은 유지 (일시적 이탈)
  • closeSession(): 세션을 완전히 종료 (영구적 종료)

이런 구분은 나중에 "재접속" 기능을 구현할 때 필수적이었습니다.

에러 처리

에러 처리 전략도 많은 고민이 필요했습니다.
특히 비동기 작업이 많은 환경에서 어떻게 에러를 전달할 것인가가 핵심이었습니다.

Promise 기반 에러는 비교적 명확했습니다

하지만 비동기 이벤트 중 발생하는 에러는 더 복잡했습니다.
연결 후 갑자기 네트워크가 끊어지거나, 미디어 디바이스가 제거되는 경우를 어떻게 처리할 것인가?

에러 중앙처리 시스템

처음 SDK를 만들 때는 에러 처리가 여기저기 흩어져 있었습니다.
컴포넌트마다 각자의 방식으로 에러를 처리하니, 일관성도 없고 디버깅도 어려웠습니다.

해결책은 중앙집중식 에러 이벤트 시스템이었습니다.

이제 SDK 내부 어디서든 에러가 발생하면 중앙 이벤트 에미터로 전달합니다.

외부에서는 하나의 채널로 모든 에러를 받습니다

이 구조의 장점

  1. 일관된 에러 처리: SDK 어디서든 같은 방식으로 에러 전달
  2. 내부/외부 분리: 내부 에러 코드(500개 이상)를 외부용으로 간소화
  3. 디버깅 용이: 에러 발생 지점과 상관없이 한 곳에서 모니터링 가능
  4. 금융권 대응: 명확한 에러 코드로 고객사와 소통 가능

하지만 이 방식에도 한계가 있었습니다.
전역 싱글톤이다 보니 여러 SDK 인스턴스를 사용할 때 문제가 생길 수 있었죠.
그래서 다음 버전에서는 각 SDK 인스턴스가 자체 EventEmitter를 가지도록 개선했습니다.

응집도와 결합도

응집도는 높이고 결합도는 낮추는 것, 이론으로는 쉽지만 실제로는 정말 어려웠습니다.

잘못된 설계
처음에는 모든 기능을 하나의 거대한 클래스에 넣었습니다.
MediaManager, ConnectionManager, MessageHandler 같은 개념이 모두 SDK 안에 뒤섞여 있었죠.

개선된 설계
각 책임을 독립적인 매니저로 분리했습니다

이렇게 하니

  • 각 매니저는 자신의 책임만 집중(=높은 응집도)
  • 다른 매니저와는 인터페이스로만 소통(=낮은 결합도)

하게 되었습니다

이벤트 모델

React에서는 useState, useEffect로 상태 변화를 처리했습니다.
하지만 SDK는 프레임워크에 독립적이어야 했습니다.

Callback 방식의 한계

처음 SDK를 만들 때는 단순한 Callback 방식을 사용했습니다.

하지만 이 방식은 금방 문제가 드러났습니다

  1. 여러 리스너 등록 불가: 하나의 이벤트에 하나의 핸들러만 가능
  2. 관리의 어려움: 10개 이상의 콜백을 일일이 프로퍼티로 관리
  3. 디버깅 어려움: 어떤 핸들러가 등록됐는지 추적 곤란

특히 금융기관마다 다른 요구사항이 생기면서 콜백이 계속 늘어났고, 결국 유지보수가 불가능해졌습니다.

왜 EventEmitter를 선택했나

다음 버전을 만들 때는 근본적인 해결책이 필요했습니다.
몇 가지 옵션을 고려했습니다

  • Callback 함수: 위에서 설명한 문제들로 제외
  • Observable (RxJS): 강력하지만 학습 곡선이 가파르고 번들 사이즈 증가
  • EventEmitter: 익숙한 패턴, 가벼움, 유연함

EventEmitter를 선택한 이유는 명확했습니다

  1. 범용적인 패턴: Node.js, 브라우저 모두에서 익숙한 패턴
  2. 프레임워크 독립적: React든, Vue든, 순수 JavaScript든 사용 가능
  3. 타입 안전성: TypeScript와 조합하면 이벤트 타입까지 안전하게 관리 가능
  4. 여러 리스너 등록: 한 이벤트에 여러 핸들러 등록 가능

이벤트 설계 원칙

이벤트를 설계하면서 지킨 원칙들

  1. 명확한 이름: 이벤트 이름만 보고 어떤 상황인지 알 수 있어야 함
  2. 일관된 페이로드: 모든 이벤트는 일관된 구조의 데이터를 전달
  3. 에러 이벤트 분리: 정상 흐름과 에러 흐름을 명확히 구분
  4. 라이프사이클 이벤트: 연결, 연결 중, 연결됨, 연결 끊김 같은 상태 변화를 모두 이벤트로 표현

레이어 분리

Infrastructure Layer

가장 하위 레이어는 실제 기술 구현을 담당합니다.
WebRTC, MQTT 같은 구체적인 기술은 모두 이 레이어에 숨겨집니다.

핵심은 추상화였습니다.
상위 레이어는 "어떤 기술을 쓰는지" 몰라도 되어야 했습니다.

예를 들어, 메시지 전송을 담당하는 Transport 계층

이렇게 하면 나중에 WebSocket이나 다른 전송 방식을 추가해도 상위 레이어는 변경할 필요가 없습니다.

Business Layer

중간 레이어는 순수한 비즈니스 로직만 담당합니다.

  • "연결 요청이 들어오면 어떻게 처리할 것인가?"
  • "미디어 스트림이 변경되면 어떻게 대응할 것인가?"

같은 도메인 로직이 여기 있습니다.

중요한 것은 이 레이어가 UI도, 구체적인 기술도 모른다는 점입니다.
순수한 TypeScript/JavaScript 로직만 있습니다.

SDK Layer

가장 상위 레이어는 외부에 노출되는 공개 API입니다.
개발자가 직접 호출하는 메서드들이 여기 있습니다.

이 레이어의 핵심은 단순함입니다.
내부가 아무리 복잡해도, 사용자는 간단한 인터페이스만 보면 됩니다.

문서화와 샘플

좋은 SDK는 코드만으로 완성되지 않습니다.
문서와 샘플이 SDK의 나머지 절반입니다.

README.md

README는 개발자가 가장 먼저 보는 문서입니다.
5분 안에 SDK의 핵심을 파악하고, 10분 안에 첫 코드를 실행할 수 있어야 합니다.

제가 따른 구조

  1. 한 문장 소개: SDK가 뭐하는 건지 명확하게
  2. 빠른 시작: 3-5줄 코드로 동작하는 예제
  3. 설치 방법: 다양한 환경별 가이드
  4. 주요 기능: 핵심 기능 간단히 설명
  5. 상세 가이드: 링크로 연결

JSDoc

TypeScript 타입 정의와 JSDoc 주석을 활용하면 IDE에서 자동완성과 함께 문서를 볼 수 있습니다.

시퀀스 다이어그램

연결 과정, 에러 처리 흐름 같은 복잡한 상호작용은 글로 설명하기 어렵습니다.
시퀀스 다이어그램이 훨씬 명확합니다.

시퀀스 다이어그램 예시

데모 페이지

가장 효과적인 문서는 동작하는 예제입니다.

데모 페이지를 만들어서

  • 모든 API를 실제로 호출해볼 수 있게
  • 다양한 시나리오를 미리 구성해서
  • 콘솔 로그로 내부 동작을 확인할 수 있게

금융기관 담당자들이 데모 페이지를 보면 SDK가 어떻게 동작하는지 바로 이해할 수 있었습니다.

현재의 문제점

SDK를 만들었지만, 여전히 해결되지 않은 문제들이 있습니다.

납품별 커스터마이징의 한계

가장 큰 문제는 각 고객사마다 요구사항이 다르다는 점입니다

  • 고객사 A: 보안 로그 수집 기능
  • 고객사 B: 화면 캡처 기능
  • 고객사 C: 커스텀 인증 방식
  • 고객사 D: 파일 전송 기능

현재는 이런 커스터마이징을 SDK 내부에 조건문으로 처리하고 있습니다

이 방식은 몇 가지 문제가 있습니다

  1. 코드 파편화: 비슷한 로직이 여기저기 흩어짐
  2. 테스트 어려움: 모든 조합을 테스트해야 함
  3. 유지보수 어려움: 한 고객사 수정이 다른 곳에 영향
  4. 배포 복잡도: 모든 고객사용 코드를 다 포함해야 함

버전 관리의 어려움

현재는 버전 관리를 수동으로 하고 있습니다.
package.json의 version 필드를 직접 수정하고, Git 태그를 수동으로 붙입니다.

문제는 고객사마다 다른 버전을 쓴다는 점입니다

  • 고객사 A: v1.2.3 사용 중
  • 고객사 B: v1.3.1 사용 중 (A에 없는 기능 포함)
  • 고객사 C: v1.2.5 사용 중 (B에 없는 다른 기능 포함)

버그 수정이 발생하면 어느 버전부터 적용해야 할지, 각 고객사는 어느 버전으로 업데이트해야 할지 추적이 어렵습니다.

향후 계획

이 문제들을 해결하기 위한 다음 단계는 Core + Plugin 구조입니다.

Day.js에서 얻은 영감

Day.js의 Plugin시스템을 보고 영감을 받았습니다

이 구조의 장점

  • Core는 범용적: 모두가 쓰는 기본 기능만
  • Plugin은 선택적: 필요한 기능만 추가
  • 독립 배포: Plugin만 따로 업데이트 가능
  • 조합 가능: 여러 Plugin을 조합해서 사용

우리 SDK에도 이를 적용하면

기능별 vs 고객사별 Plugin

Plugin 구조를 설계하면서 두 가지 방향을 고민했습니다

1. 기능별로 잘게 쪼개기 장점
  • 필요한 기능만 선택 가능
  • 번들 사이즈 최소화 (필요한 것만 다운로드)
  • 재사용성 높음
  • 테스트 용이
2. 고객사별로 묶기 장점
  • 설정이 간단함 (한 줄로 모든 기능 추가)
  • 고객사별 요구사항을 한 곳에서 관리
  • 플러그인 간 의존성 충돌 걱정 없음
  • 버전 관리가 명확함 (고객사당 하나의 버전)

결국 기능별로 잘게 쪼개는 방식이 더 유연하고 확장 가능하다고 판단했습니다.
고객사별로 필요한 Plugin 조합만 다르게 가져가면 되니까요.

구현 계획

Plugin 구조를 위해 필요한 것들

  1. Plugin 인터페이스 정의
  1. SDK에 Plugin 시스템 추가
  1. Hook 시스템 구축
    Plugin이 SDK의 특정 시점에 개입할 수 있도록 Hook 제공
    • 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) 전략을 선택할 계획입니다.

Testing Trophy

왜 테스트 트로피인가?

테스트 피라미드는 단위 테스트를 많이, E2E 테스트를 적게 가져가라고 합니다.
하지만 SDK처럼 모듈 간 상호작용이 중요한 시스템에서는 단위 테스트만으로는 충분하지 않습니다.

테스트 트로피는

  • 정적 테스트: TypeScript, ESLint (기본)
  • 단위 테스트: 순수 함수, 유틸리티 (적당히)
  • 통합 테스트: 모듈 간 상호작용 (가장 많이) ← 여기에 집중
  • E2E 테스트: 전체 시나리오 (중요한 것만)

SDK에서는

  • MediaManager와 ProtocolManager가 제대로 협력하는가
  • 에러가 발생했을 때 복구 로직이 동작하는가

같은 통합 테스트가 가장 중요합니다.

통합 테스트 설계 원칙

통합 테스트가 요구사항 변경으로 자주 깨지는 문제를 피하기 위해

  1. 사용자 행동 중심으로 작성
  1. 비즈니스 플로우 기준으로 구성
  1. Test ID 활용 (UI 구조 변경에도 테스트가 깨지지 않도록)

배운 점

추상화는 비용이다

처음에는 "모든 것을 추상화해야 한다"고 생각했습니다.
하지만 과도한 추상화는 오히려 코드를 복잡하게 만들었습니다.

배운 교훈
추상화는 반복이 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> {  
		// ...  
	}  
}