본문 바로가기

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

React 앱 컴포넌트에 강결합된 코어 로직을 프레임워크를 가리지 않는 SDK로 분리하기까지의 설계 고민. 라이브러리와 SDK의 차이, 인터페이스 네이밍, 레이어를 가르는 기준, 에러를 전달하는 방식을 실무 경험으로 정리한 아키텍처 의사결정 기록.

읽는 시간 43
강결합된 React 앱을 독립적인 SDK로 분리하기

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

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

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

예를 들어, Axios는 HTTP 요청을 쉽게 만들어주는 라이브러리예요. 반면 AWS SDK는 서비스와 통신하는 클라이언트에 인증 처리와 타입 정의, 에러 처리까지 함께 묶어 놓은 도구예요.

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

왜 SDK를 만들게 되었나

이런 서비스를 만들고 있었어요

저는 RemoteVS (새 창에서 열림)라는 비대면 상담 서비스를 개발하고 유지보수하고 있습니다. 상담이 필요한 고객이 영업점에 방문하지 않고도, 상담원과 실시간으로 소통하며 업무를 처리할 수 있는 서비스예요. 상담이 시작되면 이런 기능들을 쓸 수 있어요.

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

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

시작은 React 제품이었어요

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

// 컴포넌트에 모든 것이 섞여 있음
function VideoCall() {
	const [peerConnection, setPeerConnection] = useState<RTCPeerConnection>();
	const [dataChannel, setDataChannel] = useState<RTCDataChannel>();

	useEffect(() => {
		// WebRTC 연결 로직이 컴포넌트 안에...
		// MQTT 구독 로직도 컴포넌트 안에...
		// 비즈니스 로직도 컴포넌트 안에...
	}, []);

	return <div>{/* UI */}</div>;
}

WebRTC 연결과 MQTT 구독, 비즈니스 로직이 전부 하나의 useEffect 안에 들어가 있어요.

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

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

조건이 하나 더 있었어요. 코드를 붙이는 사람이 우리가 아니라는 것입니다. 연동은 각 기관이 데려온 외부 개발사가 하고, 그쪽은 우리 내부 구조를 몰라요. 우리도 그들의 코드와 환경에 손댈 수 없어요. 환경도 제각각이라 웹뷰로 도는 모바일 앱부터 내부망에서만 도는 데스크톱까지 있었어요.

빠르게 만든 대가로 치른 세 가지

그렇게 빨리 만든 제품에는 세 가지 문제가 있었어요.

  1. 코드가 산재: 핵심 로직이 여러 컴포넌트에 흩어져 있어 어디서부터 분리해야 할지 막막했어요.
  2. 테스트 불가능: UI와 로직이 섞여 있어 단위 테스트를 작성할 수 없었어요.
  3. 재사용 불가: React 외 다른 환경에서는 우리 코드를 쓸 방법이 없었어요.

결국 "독립적인 SDK를 만들자"는 결론에 도달했습니다. 하지만 당시 저는 3년차 주니어 개발자였고, SDK를 설계해본 경험이 전무했어요.

뜯어내는 대신 범위를 다시 그었습니다

처음에는 얽힌 코드에서 SDK가 될 부분을 뜯어내려고 했습니다. 한참을 붙잡고 있다가 방법을 바꿨어요. 어디까지가 SDK인지 정하지 않은 채로는 뜯을 선을 그을 수가 없었거든요.

그래서 앱 전체를 훑으면서 기능을 둘로 갈랐습니다. 화면과 상담 진행 흐름은 앱에 남기고, SDK로 팔 것은 영상과 통신 둘로 정했어요. 그 둘만 놓고 처음부터 새로 설계했습니다.

화면을 SDK에 넣지 않은 이유가 앞의 JSP 이야기와 이어져요. SDK가 화면까지 그리면 그 화면이 어떤 프레임워크로든 만들어져야 하고, 도입하는 쪽은 그 프레임워크에 묶여요. 그래서 SDK는 영상과 상태만 넘기고 화면은 연동 개발사가 각자 그리도록 했습니다.

이 글에서 "분리"라고 부르는 것은 그러니까 코드를 뜯어낸 작업이 아니에요. 무엇이 제품이고 무엇이 앱인지를 다시 그은 다음, 제품 쪽을 새로 지은 것입니다.

도움이 됐던 네 가지 조언

혼자서는 어려웠습니다. 주변 개발자분들께 조언을 구했고 그 조언들은 제 설계의 토대가 되었어요.

"코어를 독립적인 모듈로 분리하세요"

React에 의존하는 UI 코드와 비즈니스 로직을 완전히 분리하라는 말이었어요.

React는 그냥 UI를 그리는 도구일 뿐입니다.
핵심 로직은 React를 몰라도 동작해야 합니다.

WebRTC 연결과 실시간 메시지 전송, 미디어 관리 같은 핵심 기능은 React와 무관하게 동작할 수 있어야 했어요.

"레이어를 분리하세요"

레이어 아키텍처에 관한 조언도 받았어요.

상위 레이어는 하위 레이어만 의존해야 합니다.
하위 레이어가 상위를 알면 안 됩니다.

인프라 레이어는 비즈니스 로직을 몰라야 하고, 비즈니스 레이어는 UI를 몰라야 했어요.

"인터페이스를 먼저 설계하세요"

구현하기 전에 인터페이스부터 정하세요.
어떻게 사용될지 상상하면서요.

"어떻게 구현할까?"가 아니라 "어떻게 사용될까?"를 먼저 고민하니 불필요한 기능을 만들지 않게 됐어요. 머릿속에 있던 사용 흐름을 그대로 타입으로 옮겨 적었습니다.

// 구현 전 인터페이스부터 정의
interface RemoteVSSDKContract {
	joinRoom(): Promise<void>;
	leaveRoom(): Promise<void>;
	closeSession(): Promise<void>;
	setVideoEnabled(enabled: boolean): void;
	setAudioEnabled(enabled: boolean): void;
	sendMessage(message: unknown): Promise<boolean>;
	isConnected(): boolean;
	// ... 기타 메서드
}

메서드 이름과 인자, 반환 타입만 있고 구현은 한 줄도 없어요.

"에러 처리는 정말 중요합니다"

SDK는 예측 가능하게 실패해야 합니다.
사용자가 뭐가 잘못됐는지 알 수 있어야 해요.

금융권 특성상 문제가 생기면 원격 디버깅이 어렵거든요. 명확한 에러 메시지와 로깅이 필수적이었습니다.

좋은 인터페이스는 어떻게 생겼나

메서드 이름이 도메인을 드러내야 한다

SDK 설계에서 가장 많은 시간을 쏟은 부분은 인터페이스 네이밍이었어요. 메서드 이름 하나가 SDK 사용 경험을 좌우하고, 더 나아가 제품의 도메인 모델을 코드로 표현하는 것이기 때문이에요.

기준을 하나 세워 두고 이름을 골랐어요. 이 이름을 읽을 사람은 우리 코드를 한 줄도 안 본 외부 개발사이고, 그 사람이 이름만 보고 무슨 일이 일어날지 알아야 한다는 것이었어요. 내부 동작이 아니라 부르는 쪽이 하려는 일이 이름에 담겨야 한다는 뜻입니다.

라이프사이클 메서드

처음에는 일반적인 네이밍을 고려했어요.

  • start() vs connect() vs initialize()
  • stop() vs disconnect() vs destroy()

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

  1. 백엔드에서 상담방(Room)이 먼저 생성됨
  2. 상담원이 방을 만들고 대기
  3. 고객이 접속 코드로 그 방에 입장
  4. 두 주체가 방 안에서 원격 상담 진행

이 구조를 보고 나니 답이 명확해졌어요. 메서드 이름을 방 중심으로 다시 적었어요.

interface RemoteVSSDKContract {
	createRoom(): Promise<void>; // 상담원: 방 생성
	joinRoom(): Promise<void>; // 고객: 방 입장
	leaveRoom(): Promise<void>; // 방 나가기
}

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

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

미디어 제어

미디어 제어 메서드도 고민이 많았어요.

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

호출하는 쪽 코드를 나란히 놓고 보면 차이가 드러납니다.

// 의미가 모호함
sdk.toggleVideo(); // 켜는 건가 끄는 건가?

// 의도가 명확함
sdk.setVideoEnabled(true); // 비디오 켜기
sdk.setVideoEnabled(false); // 비디오 끄기

toggleVideo()는 호출한 뒤 비디오가 켜지는지 꺼지는지 코드만 봐서는 알 수 없어요. setVideoEnabled()는 인자에 결과가 그대로 적혀 있어요. 결국 setVideoEnabled(enabled: boolean) 형태를 선택했습니다. 조금 길더라도 의미가 명확한 것이 더 중요하다고 판단했어요.

읽기 좋은 것 말고 다른 이유도 있어요. toggle은 두 번 부르면 처음으로 돌아가고 set은 몇 번을 불러도 같은 상태가 돼요. 끊겼다 붙는 일이 잦은 환경에서 이 차이가 커요. 연결이 복구됐을 때 상태를 다시 맞추려면 "지금 켜져 있어야 한다"를 그대로 보내면 되는데, toggle밖에 없으면 먼저 현재 상태를 알아내야 하고 그 사이에 또 바뀔 수 있습니다.

세션 관리

세션 종료에는 두 가지 레벨이 있었어요. 인터페이스에서도 둘을 다른 메서드로 갈랐어요.

interface RemoteVSSDKContract {
	leaveRoom(): Promise<void>; // 방에서 나가기 (재입장 가능)
	closeSession(): Promise<void>; // 세션 완전 종료 (재입장 불가)
}
  • leaveRoom(): 방에서 나가지만 세션은 유지 (일시적 이탈)
  • closeSession(): 세션을 완전히 종료 (영구적 종료)

이런 구분은 나중에 "재접속" 기능을 만들 때 꼭 필요했어요.

쓰기 나쁜 인터페이스에는 공통점이 셋 있어요

이름을 정하고 나니 더 큰 질문이 남았어요. 좋은 인터페이스와 나쁜 인터페이스를 가르는 것이 이름뿐일까요.

쓰기 나쁜 인터페이스에서 자주 보이는 모양이 셋 있어요.

부르는 순서를 외워야 하는 것. init()을 먼저 부르고 connect()를 부른 다음 start()를 불러야 동작한다면, 그 순서는 어디에도 적혀 있지 않고 문서에만 있습니다. 연동 개발사가 순서를 틀리면 아무 일도 안 일어나거나 엉뚱한 에러가 나요. 순서가 필요하면 그 순서를 SDK가 안에서 지키고 바깥에는 메서드 하나만 내보내는 편이 나아요.

같은 일을 두 가지 방법으로 할 수 있는 것. 비디오를 끄는 방법이 setVideoEnabled(false)videoTrack.enabled = false 둘이라면, 둘 중 하나로만 끈 상태에서 다른 쪽 값을 읽었을 때 무엇이 맞는지 아무도 몰라요. 끄는 방법이 둘이면 지금 켜져 있는지를 알려주는 값도 둘이 돼요.

물어본 다음 행동하게 시키는 것. 쓰는 쪽에 "연결됐는지 먼저 확인하고 보내세요"를 요구하는 구조가 그렇습니다.

// 쓰는 쪽이 확인하고 보내는 구조
if (sdk.isConnected()) {
	// 이 줄과 다음 줄 사이에 끊기면 아무도 모릅니다
	sdk.sendMessage(message);
}

// 확인을 안으로 넣고 결과를 돌려주는 구조
const sent = await sdk.sendMessage(message);
if (!sent) {
	// 다시 보낼지 사용자에게 알릴지 여기서 정합니다
}

위쪽은 isConnected()가 참을 돌려준 뒤 sendMessage()가 불리기 전에 연결이 끊기면 막을 방법이 없어요. 보낸 줄 알았는데 안 간 메시지가 생깁니다. 아래쪽은 물어볼 일이 없고, 실패했는지가 호출 결과로 돌아옵니다. 확인은 SDK가 안에서 하고 바깥에는 그냥 보내는 메서드 하나만 두는 편이 낫습니다.

셋을 뒤집으면 좋은 인터페이스의 조건이 나와요. 틀리게 쓸 방법이 없는 것. 앞에서 toggleVideo() 대신 setVideoEnabled(boolean)을 고른 것도 같은 이야기였어요. 몇 번을 불러도 결과가 같으니 부르는 쪽이 현재 상태를 추적하지 않아도 됩니다.

공개하지 않기로 정하는 것도 설계입니다

인터페이스에서는 무엇을 넣을지만큼 무엇을 넣지 않을지가 중요해요.

내부 객체를 그대로 내보내면 당장은 편합니다. 연동 개발사가 필요한 것을 알아서 꺼내 쓰면 되니까요. 그런데 한 번 내보내고 나면 그 객체의 모양이 공개 인터페이스가 돼요. 내부 리팩터링을 하려는데 누군가 그 필드를 이미 쓰고 있으면 못 고칩니다.

WebRTC를 쓰는 SDK라면 RTCPeerConnection이 그런 자리예요. 넘겨주면 받는 쪽이 편한데, 그걸로 연결을 직접 건드리기 시작하면 SDK는 지금 상태가 어떤지 알 수 없게 됩니다.

공개 표면이 좁을수록 고칠 수 있는 것이 많아져요. 밖에서 보이지 않는 것은 언제든 바꿔도 아무도 모르니까요.

에러를 어떻게 전달할 것인가

처음에는 에러를 한 가지로 봤어요. 어디서 나든 한 군데로 모아서 내보내면 된다고 생각했습니다. 쓰다 보니 그게 아니었어요. 부른 일이 실패한 것과 아무도 안 부른 자리에서 난 것은 성격이 다릅니다. 가르는 기준은 그 결과를 기다리는 사람이 있느냐예요. 지금은 둘을 갈라서 다르게 내보냅니다.

부른 쪽이 기다리고 있을 때

먼저 쉬운 쪽입니다. joinRoom()을 부른 쪽은 그 결과를 기다리고 있으니 실패를 그 자리에서 돌려주면 돼요. Promise가 거절되고 try/catch로 받습니다.

try {
	await sdk.joinRoom();
} catch (error) {
	// error.code에 무엇이 잘못됐는지가 담겨 옵니다
	if (error.code === RVS_ERROR_CODE.ERROR_PERMISSION_DENIED) {
		// 카메라 권한 거부 처리
	} else if (error.code === RVS_ERROR_CODE.ROOM_NOT_FOUND) {
		// 없는 방 처리
	} else if (error.code === RVS_ERROR_CODE.ERROR_OVER_LICENSE_COUNT) {
		// 상담원이 모두 사용 중일 때 처리
	}
}

joinRoom()을 호출한 쪽이 권한 거부와 없는 방을 각각 다른 분기에서 받습니다. 코드 목록은 아래에서 정합니다.

아무도 기다리지 않을 때

어려운 쪽은 이쪽이에요. 연결된 지 십 분 뒤에 네트워크가 끊기거나 카메라가 뽑히는 경우입니다. 누가 불러서 일어난 일이 아니라 돌려줄 곳이 없어요.

여기서 예외를 던지면 그 예외가 어디로 갈지 우리가 정할 수 없습니다. 연동 개발사의 코드 한가운데서 터지거나, 아무도 잡지 않아 조용히 사라져요. 둘 다 곤란합니다. 우리 SDK 안에서 난 일이 남의 코드를 멈춰 세우면 안 되고, 그렇다고 소리 없이 사라지면 원격 디버깅이 더 어려워지거든요.

그래서 이쪽 오류는 예외로 새어 나가지 않게 막고 에러 이벤트 하나로 모아 내보냅니다.

처음 SDK를 만들 때는 이 처리가 여기저기 흩어져 있었어요. 컴포넌트마다 각자의 방식으로 에러를 다루니 일관성도 없고 디버깅도 어려웠습니다. 그래서 외부에 노출할 에러 코드를 먼저 정의하고, 그 코드를 실어 나를 이벤트 에미터를 하나 만들었어요.

import EventEmitter from "eventemitter3";

// 1. 외부에 노출할 에러 코드 정의
export enum RVS_ERROR_CODE {
	/** 공개 메서드에 잘못된 값이 들어옴 */
	INVALID_PARAMETER = 40000,

	/** 카메라 또는 마이크 권한 거부 */
	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;
}>();

에러 코드는 숫자 하나로 고정돼요. 에미터가 다루는 이벤트는 ERROR 하나뿐입니다. 기다리는 쪽이 없는 오류는 여기로 보냅니다. 연결이 끊기는 것을 지켜보는 코드와 장치가 사라지는 것을 지켜보는 코드가 그 예예요.

// SDK 내부: 연결이 끊기는 것을 지켜보는 자리
private watchConnection(peer: RTCPeerConnection) {
	peer.addEventListener("connectionstatechange", () => {
		if (peer.connectionState !== "failed") return;

		rvsErrorEmitter.emit("ERROR", RVS_ERROR_CODE.WEBRTC_CONNECTION_FAILED);
	});
}

// SDK 내부: 쓰던 카메라가 사라지는 것을 지켜보는 자리
private watchDevices() {
	navigator.mediaDevices.addEventListener("devicechange", async () => {
		const devices = await navigator.mediaDevices.enumerateDevices();
		if (devices.some((device) => device.kind === "videoinput")) return;

		rvsErrorEmitter.emit("ERROR", RVS_ERROR_CODE.ERROR_DEVICE_NOT_FOUND);
	});
}

둘 다 브라우저가 알려주는 것을 받아 에러 코드로 바꾼 뒤 같은 에미터로 넘겨요. 누가 불러서 일어난 일이 아니라 결과를 돌려줄 곳이 없어요. 오류가 나는 자리는 여럿인데 나가는 통로는 하나예요.

// 사용자 코드
rvsErrorEmitter.on("ERROR", (errorCode) => {
	switch (errorCode) {
		case RVS_ERROR_CODE.WEBRTC_CONNECTION_FAILED:
			alert("연결이 끊어졌습니다. 잠시 후 다시 시도해주세요");
			break;
		case RVS_ERROR_CODE.ERROR_DEVICE_NOT_FOUND:
			alert("카메라를 찾을 수 없습니다. 연결을 확인해주세요");
			break;
		// ... 기타 에러 처리
	}
});

SDK를 쓰는 쪽은 리스너 하나를 걸고, 에러 코드마다 사용자에게 보여줄 문구를 고르면 돼요.

중앙 에미터로 모으고 나서 네 가지가 달라졌어요.

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

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

// 개선된 버전: 인스턴스별 에러 이벤트
const sdk = new RemoteVSSDK(config);

sdk.on(RemoteVSSDK.Events.ERROR, (errorCode) => {
	console.error("에러 발생:", errorCode);
});

리스너를 거는 자리가 전역 상수에서 SDK 인스턴스로 옮겨왔습니다.

타입 선언은 쓰는 쪽을 붙잡아주지 않아요

에러 이야기를 하다 보면 에러가 생기기 전으로 거슬러 가게 됩니다. 잘못된 값이 들어오는 것을 애초에 막으면 되지 않을까요.

여기서 한참을 헛짚었어요. 저는 TypeScript로 인터페이스를 촘촘하게 짜 놓았으니 잘못 부르면 빨간 줄이 뜰 거라고 생각했거든요. 그런데 그건 우리 저장소 안에서만 맞는 말이었습니다.

앞에서 말한 JSP 쪽을 떠올려 보면 이유가 보여요. 그쪽에는 TypeScript가 없습니다. 빌드 도구도 없이 script 태그로 파일을 불러다 쓰는 자리도 있어요. 타입 선언은 컴파일할 때만 일하는데 컴파일을 안 하니까 아무것도 막지 못해요. joinRoom()에 숫자를 넣어도, 필수 값을 빼먹어도 그냥 실행됩니다.

그래서 공개 메서드마다 들어온 값을 실행 중에 다시 확인합니다.

public async joinRoom(options: JoinRoomOptions): Promise<void> {
	// 타입 선언을 지나지 않고 들어온 호출을 여기서 받습니다
	if (typeof options?.accessCode !== "string" || options.accessCode === "") {
		throw new RVSError(RVS_ERROR_CODE.INVALID_PARAMETER, "accessCode가 비어 있습니다");
	}

	// 여기부터는 값이 맞다고 믿고 씁니다
}

거절에 코드와 문구가 함께 실려서 어느 인자가 잘못됐는지 바로 알 수 있어요. 진입점에서 막지 않으면 한참 뒤 엉뚱한 곳에서 터집니다.

타입 선언과 런타임 검증은 둘 중 하나를 고르는 것이 아니라 맡는 구간이 다릅니다. 타입은 우리 저장소 안을 지키고, 런타임 검증은 바깥에서 들어오는 호출을 받아요.

책임을 어떻게 나눌 것인가

응집도는 높이고 결합도는 낮추는 것, 이론으로는 쉽지만 실제로는 정말 어려웠습니다. 처음에는 모든 기능을 하나의 거대한 클래스에 넣었어요. 카메라를 다루는 코드와 메시지를 주고받는 코드, 상대에게 알림을 보내는 코드가 한 파일에 섞여 있었어요.

// 나쁜 예: 하나의 클래스에 모든 책임
class RemoteVSSDK {
	private setupMedia() {
		/* ... */
	}

	private connectWebRTC() {
		/* ... */
	}

	private sendMessage() {
		/* ... */
	}

	private handleIncomingMessage() {
		/* ... */
	}

	// ... 수백 줄의 코드
}

미디어 설정과 WebRTC 연결, 메시지 송수신이 전부 한 클래스의 private 메서드로 들어가 있어요.

쪼갤 때 기준이 필요했어요. 처음에는 메서드를 하나씩 보면서 비슷한 것끼리 묶으려고 했는데 잘 안 됐습니다. connectWebRTC()sendMessage()는 둘 다 통신이라 같이 두고 싶은데, 막상 같이 두면 연결이 끊겼을 때 고칠 코드와 메시지 형식이 바뀌었을 때 고칠 코드가 한 파일에 섞이거든요.

그래서 기준을 바꿨습니다. 하는 일이 아니라 바뀌는 이유로 갈랐어요. 카메라와 마이크를 다루는 코드는 장치를 다루는 방식이 바뀔 때 손대고, 메시지를 주고받는 코드는 프로토콜이 바뀔 때 손대요. 이유가 다르면 한 덩어리로 두지 않았어요.

// 좋은 예: 책임별 분리
class MediaDeviceManager {
	// 카메라와 마이크 장치. setupMedia()가 하던 일
}

class ProtocolManager {
	// 메시지 형식과 송수신. sendMessage()와 handleIncomingMessage()가 하던 일
}

class PushClientManager {
	// 상대에게 알림을 밀어 넣는 연결
}

class RemoteVSSDK extends EventEmitter {
	private readonly mediaDeviceManager: MediaDeviceManager;
	private readonly protocolManager: ProtocolManager;
	private readonly pushClientManager: PushClientManager;

	constructor(
		config: RemoteVSSDKConfig,
		mediaDeviceManager: MediaDeviceManager,
		protocolManager: ProtocolManager,
		pushClientManager: PushClientManager
	) {
		super();
		this.mediaDeviceManager = mediaDeviceManager;
		this.protocolManager = protocolManager;
		this.pushClientManager = pushClientManager;
	}
}

RemoteVSSDK는 매니저 셋을 직접 만들지 않고 생성자에서 받아 들고 있을 뿐이에요.

이렇게 나누니 매니저마다 자기 책임에만 집중하게 됐어요. 어떤 구현을 넣을지는 바깥에서 정하니 매니저를 갈아 끼울 때 SDK 코드를 고치지 않아도 됩니다. 응집도를 높이고 결합도를 낮춘다는 말이 코드에서는 이런 모습이에요.

connectWebRTC()는 셋 중 어디에도 들어가지 않았어요. 연결을 세우고 유지하는 일은 매니저 셋이 공통으로 쓰는 것이라 한 층 아래로 내려갔어요. 매니저는 "메시지를 보낸다"까지만 알고 그 메시지가 어느 전송 방식으로 나가는지는 몰라요. 그 아래층이 뒤에서 볼 Transport예요.

Callback에서 EventEmitter로

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

Callback 방식의 한계

처음 SDK를 만들 때는 단순한 Callback 방식을 사용했어요. 핸들러를 프로퍼티로 하나씩 받아두고 필요할 때 불러주는 구조였어요.

// 초기 버전: 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();
		}
	}
}

핸들러를 담아둘 private 필드를 두고, 바깥에서 채우도록 setter를 열어 뒀습니다. 내부에서는 notify 메서드가 그 핸들러를 불러요. 하지만 이 방식은 금방 문제가 드러났습니다.

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

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

왜 EventEmitter를 선택했나

다음 버전에서는 콜백을 프로퍼티로 하나씩 다는 방식 자체를 바꾸기로 했어요. 후보가 셋이었어요.

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

EventEmitter는 Node에서 온 패턴이라 익숙하고, 브라우저에서 쓸 구현도 따로 나와 있어요. React든 Vue든 순수 JavaScript든 그대로 붙고, TypeScript와 함께 쓰면 이벤트 이름과 인자 타입까지 잡힙니다. 콜백 방식과 달리 한 이벤트에 리스너를 여럿 걸 수 있다는 점도 있었어요.

여기서 한 번 헤맸어요. EventEmitter라는 이름을 쓰는 구현이 여럿인데 타입을 적는 방법이 서로 다릅니다. 앞의 코드는 eventemitter3를 쓴 것이고, Node 내장 events에 그대로 넣으면 컴파일되지 않아요.

// eventemitter3: 리스너 모양을 그대로 적습니다
new EventEmitter<{ ERROR: (errorCode: RVS_ERROR_CODE) => void }>();

// Node 내장 events: 인자를 배열로 적습니다
new EventEmitter<{ ERROR: [RVS_ERROR_CODE] }>();

어느 쪽을 쓰는지 문서에 적어 두지 않으면 연동 개발사가 예제를 복사했을 때 첫 줄에서 막힙니다.

이벤트 이름은 네임스페이스를 앞에 붙여 상수 하나로 모았어요.

/**
 * 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",

	// Session (세션) 상태와 에러
	SESSION_CONNECTED: "session:connected",
	ERROR: "session:error"

	// ... 기타 이벤트 추가
} as const;

class RemoteVSSDK extends EventEmitter {
	static readonly Events = SDKEvents;
}

// 사용 예시
sdk.on(RemoteVSSDK.Events.REMOTE_LEAVE, () => {
	console.log("상대방이 나갔습니다");
});

local:remote:, session: 접두사가 이벤트를 세 갈래로 나누고, 사용하는 쪽은 문자열을 직접 적는 대신 RemoteVSSDK.Events에서 이름을 꺼내 씁니다.

하위 레이어가 상위를 알면 안 된다

레이어를 셋으로 나눈 기준은 "무엇을 하는가"가 아니라 "무엇 때문에 바뀌는가"였어요. 하는 일로 가르면 경계가 취향처럼 보이는데, 바뀌는 이유로 가르면 왜 하필 거기인지가 설명됩니다.

공개 API는 연동 개발사가 부르는 메서드와 이벤트가 바뀔 때 바뀌어요. 비즈니스 레이어는 상담을 진행하는 방식이 바뀔 때 바뀝니다. 인프라 레이어는 쓰는 기술이 바뀔 때 바뀌어요. 같은 이유로 바뀌는 것끼리 묶고 다른 이유로 바뀌는 것을 갈랐습니다.

이렇게 보면 화면을 SDK 밖에 둔 것도 같은 판단이었어요. 화면은 고객사가 로고를 바꾸거나 버튼을 옮기거나 안내 문구를 고칠 때마다 바뀝니다. 우리는 그 일정을 모르고, 안다 해도 문구 하나 때문에 SDK를 새로 내보낼 수는 없어요.

Infrastructure Layer

가장 하위 레이어는 실제 기술 구현을 담당해요. WebRTC, MQTT 같은 구체적인 기술은 모두 이 레이어에 숨겨져요. 핵심은 추상화였어요. 상위 레이어는 "어떤 기술을 쓰는지" 몰라도 되어야 했어요.

예를 들어, 메시지 전송을 담당하는 Transport 계층은 이렇게 생겼습니다.

import type { MqttClient } from "mqtt";

// 추상 베이스 클래스
abstract class BaseTransport {
	public abstract send(message: unknown): void;
	public abstract isConnected(): boolean;
}

// 구현체 1: WebRTC DataChannel
class DataChannelTransport extends BaseTransport {
	constructor(private channel: RTCDataChannel) {
		super();
	}

	public send(message: unknown) {
		this.channel.send(JSON.stringify(message));
	}

	public isConnected() {
		return this.channel.readyState === "open";
	}
}

// 구현체 2: MQTT
class MQTTTransport extends BaseTransport {
	constructor(
		private client: MqttClient,
		private topic: string
	) {
		super();
	}

	public send(message: unknown) {
		this.client.publish(this.topic, JSON.stringify(message));
	}

	public isConnected() {
		return this.client.connected;
	}
}

BaseTransport가 요구하는 것은 send()isConnected() 둘뿐이에요. DataChannel 구현체는 readyState로, MQTT 구현체는 클라이언트의 연결 상태로 그 둘을 각자 채웁니다. 위층은 어느 쪽이 실려 있는지 몰라요. 나중에 WebSocket을 더해도 같은 둘만 채우면 되고 위층 코드는 그대로입니다.

Business Layer

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

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

같은 도메인 로직이 여기 있어요.

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

SDK Layer

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

이 층만 성격이 다릅니다. 첫 납품이 나가는 순간 고칠 수 없게 돼요. 이름을 바꾸면 연동 개발사 코드가 깨지니까요. 아래 두 층은 반대예요. 비즈니스 레이어는 상담 진행 방식이 바뀌면 갈아엎어도 되고, 인프라 레이어는 전송 방식을 통째로 바꿔도 돼요. 밖에서 보이는 것이 그대로면 아무도 모르거든요.

그래서 이 층을 얇게 가져갑니다. 여기에 올린 것만큼 앞으로 못 고치는 것이 늘어나요. 내부가 복잡한 것은 나중에 정리할 수 있지만, 공개 API가 넓은 것은 정리할 방법이 없어요.

코드만으로는 SDK가 완성되지 않는다

여기서 말하는 문서는 전부 연동 개발사가 볼 것입니다. 우리 팀이 볼 설계 문서가 아니에요. 그쪽은 우리 코드를 한 줄도 안 보고, 물어볼 사람도 없이 자기 화면에 붙여야 합니다. 문서가 부실하면 그 자리에서 막혀요.

어떤 형태로 건네줄 것인가

문서 이야기를 하기 전에 배포 형태를 먼저 정해야 했어요. 아무리 잘 만들어도 상대 환경에 올라가지 않으면 소용이 없으니까요.

npm 패키지 하나면 끝날 줄 알았는데 그게 안 됐어요. JSP 쪽에는 번들러가 없어서 import 한 줄을 쓸 수가 없습니다. 반대로 script 태그용 파일만 주면 React 쪽이 번들러를 쓰는 이점을 다 버려야 합니다.

그래서 같은 소스에서 세 가지를 뽑아냈어요. 파일 하나만 서버에 올리면 script 태그로 바로 쓸 수 있는 단일 파일, 그리고 번들러를 쓰는 쪽을 위한 CJS와 ESM입니다. 연동 개발사는 자기 환경에 맞는 것을 가져가면 되고, 우리는 코드를 한 곳에서만 관리해요.

단일 파일에는 의존성이 전부 들어 있어요. 크기는 커지지만 받는 쪽에서 할 일이 없어집니다. 파일을 올리고 태그를 거는 것으로 끝이에요.

크기를 줄이는 일은 형태를 고르는 데서 시작합니다

번들 크기를 줄이는 방법을 찾다 보면 압축 설정 같은 것부터 손대게 되는데, 실제로 효과가 큰 순서는 그 반대였어요.

먼저 의존을 줄입니다. 라이브러리 하나가 들어오면 그 라이브러리가 의존하는 것들도 같이 들어와요. 날짜 하나 다루려고 라이브러리를 넣었다가 그것 때문에 파일이 몇 배가 되는 일이 생깁니다. 압축 설정을 아무리 만져도 이만큼 줄지는 않아요. 앞에서 의존을 안 만드는 쪽을 기본으로 뒀는데, 크기를 줄이는 데도 그게 가장 크게 작용했습니다.

그다음이 형태입니다. 번들러를 쓰는 쪽에 ESM을 같이 주는 이유가 여기 있어요. ESM은 어떤 기능을 실제로 쓰는지 번들러가 읽어낼 수 있어서 안 쓰는 코드를 떼어내요. 단일 파일은 그게 안 돼요. 통째로 받아야 합니다. 같은 소스에서 형태를 셋 뽑는 것이 손이 더 가지만, 번들러가 있는 쪽은 필요한 만큼만 가져갈 수 있습니다.

마지막이 파일을 쪼개서 필요할 때 받는 것입니다. 화면 공유나 파일 전송은 상담이 시작된 뒤에야 써요. 처음 화면을 띄울 때 같이 내려받을 이유가 없어요.

다만 이건 쓰지 않았어요. 쪼갠 조각은 쓰는 순간에 서버에서 받아 옵니다. 그러려면 연동 개발사가 그 조각들도 자기 서버에 미리 올려 둬야 해요. 파일 하나만 올리면 된다고 해 놓고 말을 바꿀 수는 없었습니다.

README.md

README는 연동 개발사가 가장 먼저 여는 문서예요. 5분 안에 이 SDK가 뭘 하는지 알고, 10분 안에 첫 코드가 돌아가야 합니다. 저는 이런 순서로 짰어요.

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

JSDoc

타입 선언 파일을 같이 내보내면 연동 개발사의 편집기에 자동완성과 설명이 함께 뜹니다. 문서를 따로 찾지 않아도 되는 자리라 여기에 가장 많이 적었어요. 클래스에는 바로 실행해 볼 수 있는 예제를, 메서드에는 어떤 에러가 나는지를 적었습니다.

/**
 * 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 ERROR_PERMISSION_DENIED 미디어 권한이 없을 때
	 * @throws ROOM_NOT_FOUND 존재하지 않는 방일 때
	 */
	async joinRoom(): Promise<void> {
		// ...
	}
}

@example에 적은 코드가 SDK 생성부터 이벤트 등록, 방 입장까지를 한 번에 보여주고, @throwsjoinRoom()이 실패하는 두 가지 경우를 알려줍니다.

시퀀스 다이어그램

연결 과정처럼 여러 쪽이 순서대로 주고받는 일은 글로 적으면 따라가기 어려워요. 연동 개발사가 "내가 뭘 부르면 다음에 뭐가 오는지"를 알아야 하는데, 누가 언제 무엇을 보내는지는 그림으로 보는 편이 낫습니다.

데모 페이지

가장 잘 통한 문서는 동작하는 예제였어요.

데모 페이지에서는 모든 API를 실제로 호출해 볼 수 있고, 시나리오를 미리 구성해 두고, 콘솔 로그로 내부 동작까지 확인할 수 있게 했습니다. 연동 개발자는 여기서 자기가 부를 순서를 미리 밟아 볼 수 있어요.

여기는 읽는 사람이 하나 더 있었습니다. 도입을 결정하는 금융기관 담당자예요. 그쪽은 코드를 읽지 않지만 화면은 봅니다. 데모를 띄워 놓고 설명하면 문서 열 장보다 빨랐어요.

한 번 공개한 인터페이스는 되돌릴 수 없습니다

납품하고 나서 성격이 달라진 것이 있어요. 그 전까지는 마음에 안 드는 이름을 발견하면 고치면 됐습니다. 납품 뒤에는 그 이름이 남의 회사 코드 안에 들어가 있어요.

무엇을 바꾸면 깨지는가

기준을 세워야 했어요. 저는 이렇게 갈랐어요. 연동 개발사가 이미 적어 놓은 코드를 고쳐야 하면 깨는 변경이고, 아니면 아닙니다.

이 기준으로 보면 깨는 것과 아닌 것이 갈려요.

깨는 것깨지 않는 것
메서드 이름을 바꾼다메서드를 새로 더한다
인자를 하나 더 받는다(필수로)인자를 선택으로 더한다
이벤트 이름을 바꾼다이벤트를 새로 더한다
이벤트가 싣고 가던 필드를 뺀다필드를 더한다
에러 코드의 뜻을 바꾼다에러 코드를 새로 더한다

오른쪽 칸에는 공통점이 있어요. 전부 더하기입니다. 그래서 규칙을 하나로 줄였어요. 납품한 뒤로는 더하기만 합니다. 이름이 마음에 안 들면 옛 이름을 남겨 둔 채 새 이름을 옆에 붙이고, 옛 이름은 문서에서만 조용히 내립니다.

이렇게까지 하는 이유는 고치라고 말할 상대가 남의 회사 개발자이기 때문이에요. 그쪽은 그쪽 배포 일정이 있고 금융권이면 그 일정이 분기 단위예요. 우리가 이름 하나를 바꾸면 그 비용을 여러 회사가 각자 냅니다.

그리고 앞 절에서 공개 표면을 좁게 가져간 것이 여기서 도움이 돼요. 공개한 것이 적을수록 더하기만으로 버틸 수 있는 기간이 길어져요. 내부 객체를 그대로 내보냈다면 그 객체의 필드 하나를 바꿀 때마다 깨는 변경이 됐을 겁니다.

우리가 쓰는 라이브러리를 소비자도 쓰고 있을 때

지금까지는 우리가 쓴 코드 이야기였어요. 더 까다로운 것은 우리가 가져다 쓰는 라이브러리입니다.

예를 들어 채팅 SDK를 만든다고 할게요. 이 SDK는 채팅 화면까지 그려 주느라 React 19를 씁니다. 그런데 이걸 붙이려는 개발사는 React 18에 묶여 있어요. 이럴 때 어떻게 해야 할까요.

그 개발사가 게을러서가 아닙니다. 쓰던 다른 라이브러리가 아직 19를 지원하지 않거나, 버전을 올리려면 사내 승인을 다시 받아야 하는 경우가 많아요. 그리고 SDK를 만든 쪽은 그 개발사 코드에 손댈 수 없어요.

방법이 셋인데 전부 대가가 있어요.

소비자에게 맡긴다. SDK가 React를 갖고 있지 않고 "18 이상을 준비해 주세요"라고 요구하는 방식이에요. package.jsonpeerDependencies에 그 요구를 적어 둡니다. 화면에 React가 하나만 떠서 깔끔한데, 소비자가 그 버전을 못 맞추면 아예 못 써요.

SDK 번들에 말아 넣는다. 소비자가 무엇을 쓰든 상관없어져요. 대신 화면에 React가 둘 실립니다. 파일이 커지는 것도 문제지만 더 큰 것이 따로 있어요. React는 훅 상태를 자기 안에 들고 있어서, 둘이 각자 상태를 따로 들면 훅이 깨져요.

애초에 의존하지 않는다. 제일 확실하고 제일 귀찮아요. 직접 짜야 하니까요.

셋 중에 무엇을 고를지는 그 라이브러리 성격이 정해요. 소비자와 React를 주고받는 SDK라면 첫 번째입니다. 채팅 화면을 그려 주는 SDK는 소비자가 넘긴 컴포넌트를 안에 끼우거나 ref를 주고받게 되는데, 그 경계를 React 둘이 가로지르면 위에서 말한 훅 문제가 그대로 터져요. 그래서 화면을 그려 주는 라이브러리는 대부분 React를 peerDependencies에 둡니다. 반대로 날짜 계산이나 id 생성처럼 상태를 갖지 않는 것은 두 번째가 편해요. 둘이 실려도 서로 간섭하지 않고 소비자는 설치할 것이 없어집니다.

다만 조건이 하나 붙어요. peerDependencies는 npm으로 받아 갈 때만 작동합니다. script 태그로 파일을 내려받는 쪽에는 package.json을 읽어 줄 도구가 없어요. 앞에서 단일 파일을 같이 내줬다고 했는데, 그 파일을 쓰는 연동 개발사에게는 첫 번째 방법이 성립하지 않습니다.

우리 SDK는 세 번째를 기본으로 뒀고 꼭 필요한 것만 번들에 넣었어요. 넣은 것들은 상태를 갖지 않아서 둘이 실려도 문제가 없었습니다.

애초에 이 고민을 크게 하지 않아도 됐던 이유가 앞 절에 있어요. 화면을 SDK 밖에 뒀으니 React를 의존할 일이 없었거든요. 방금 든 채팅 SDK가 React에 묶인 것도 화면을 자기가 그리기 때문이에요. 우리에게 남은 것은 WebRTC나 WebSocket처럼 브라우저가 주는 기능이라 버전이라는 것 자체가 없습니다.

아직 풀지 못한 두 가지

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

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

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

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

지금은 이런 커스터마이징을 SDK 안에서 조건문으로 처리해요.

// 현재 구조의 한계
if (clientType === "CLIENT_A") {
	enableSecurityLog();
	enableReconnection();
}

if (clientType === "CLIENT_B") {
	enableScreenCapture();
	enableCustomAuth();
}

// ... 계속 추가됨

고객사 코드로 분기해서 그 고객사에만 필요한 기능을 켭니다. 고객사가 하나 늘 때마다 이 if 블록도 하나씩 늘어나요. 이 방식에는 문제가 몇 가지 있어요.

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

버전 관리의 어려움

현재는 버전 관리를 수동으로 하고 있어요. package.jsonversion 필드를 직접 수정하고, Git 태그를 수동으로 붙여요. 문제는 고객사마다 다른 버전을 쓴다는 점입니다.

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

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

바로 앞 절에서 말한 추가만 하는 규칙이 이걸 덜 아프게는 해줍니다. 어느 버전에 머물러 있어도 깨지지는 않으니까요. 다만 누가 어느 버전을 쓰고 있는지 우리가 알 방법이 여전히 없습니다.

Plugin 구조와 통합 테스트

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

Day.js의 Plugin 방식

Day.js의 Plugin (새 창에서 열림) 시스템이 이 구조를 그대로 보여줍니다.

import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";

dayjs.extend(utc);
dayjs.extend(timezone);

코어는 그대로 두고 필요한 플러그인만 extend()로 끼워 넣는 방식이에요.

이 구조에는 네 가지 이점이 있어요.

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

우리 SDK에도 이를 적용하면

const sdk = new RemoteVSSDK(config);

// 그 고객사에 필요한 것만 얹습니다
sdk.use(ClientAPlugin);

await sdk.joinRoom();

이런 모습이 됩니다. use()로 얹는 Plugin만 갈아 끼우면 joinRoom()을 부르는 코드는 그대로예요. 무엇을 Plugin 하나로 묶을지는 바로 아래에서 정합니다.

기능별 vs 고객사별 Plugin

Plugin 구조를 설계하면서 두 가지 방향을 고민했어요.

하나는 기능별로 잘게 쪼개는 방향이에요.

sdk.use(SecurityLogPlugin); // 보안 로그
sdk.use(ScreenCapturePlugin); // 화면 캡처
sdk.use(ReconnectionPlugin); // 재접속

기능 하나가 Plugin 하나가 되고, 이런 장점이 있어요.

  • 필요한 기능만 선택 가능
  • 번들 사이즈 최소화 (필요한 것만 다운로드)
  • 재사용성 높음
  • 테스트 용이

다른 하나는 고객사별로 묶는 방향이에요.

sdk.use(ClientAPlugin); // A사용 모든 기능
sdk.use(ClientBPlugin); // B사용 모든 기능

고객사 하나가 Plugin 하나가 되고, 이런 장점이 있어요.

  • 설정이 간단함 (한 줄로 모든 기능 추가)
  • 고객사별 요구사항을 한 곳에서 관리
  • 플러그인 간 의존성 충돌 걱정 없음
  • 버전 관리가 명확함 (고객사당 하나의 버전)

잘게 쪼갤수록 조합이 자유롭고 재사용도 되니까 기능별이 나아 보여요. 그런데 고객사별로 묶는 쪽으로 기울었습니다.

고객사마다 요구가 너무 달라서 기능 단위로 안 쪼개지거든요. 이름이 같은 기능도 고객사마다 동작이 다릅니다. A사의 보안 로그는 남길 항목과 보관 기간이 그 회사 규정을 따르고, B사는 다른 규정을 따라요. 둘을 SecurityLogPlugin 하나로 만들면 그 안이 다시 고객사별 분기로 채워집니다. 지금 SDK 안에 있는 if 덩어리를 Plugin 안으로 옮기는 것뿐이에요.

그리고 고객사별로 묶으면 앞 절에서 못 푼 버전 문제가 같이 풀립니다. 고객사 하나에 Plugin 하나이고 버전도 하나라, 누가 무엇을 쓰고 있는지 추적할 수 있어요.

대신 내주는 것도 있습니다. 같은 기능을 여러 고객사가 쓰면 코드가 그만큼 중복되고, 공통 부분을 고칠 때 Plugin을 여러 개 손봐야 해요. 기능별로 갈 때보다 번들도 커집니다.

인터페이스와 use() 메서드, Hook 시스템

Plugin 구조를 만들려면 세 가지가 필요해요.

  1. Plugin 인터페이스 정의

Plugin이 최소한 갖춰야 할 형태부터 정합니다.

interface RemoteVSSDKPlugin {
	name: string;
	version: string;
	install(sdk: RemoteVSSDK): void;
}

이름과 버전, 그리고 SDK에 자기를 설치하는 install() 하나면 돼요.

  1. SDK에 Plugin 시스템 추가

SDK 쪽에는 Plugin을 받아 설치하는 use()를 둡니다.

class RemoteVSSDK extends EventEmitter {
	private plugins: RemoteVSSDKPlugin[] = [];

	public use(plugin: RemoteVSSDKPlugin) {
		plugin.install(this);
		this.plugins.push(plugin);
		return this; // chaining 지원
	}
}

install()을 호출하고 목록에 담은 다음 자기 자신을 돌려주기 때문에 use()를 이어 붙여 쓸 수 있어요.

  1. Hook 시스템 구축
    Plugin이 SDK의 특정 시점에 개입할 수 있도록 Hook 제공
    • beforeConnect: 연결 전
    • afterConnect: 연결 후
    • onMessage: 메시지 수신 시
    • onError: 에러 발생 시

통합 테스트가 가장 중요하다

SDK의 안정성을 위해 체계적인 테스트 전략이 필요해요. 테스트 피라미드가 아닌 테스트 트로피(Testing Trophy) 전략을 선택할 계획이에요.

왜 테스트 트로피인가

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

테스트 트로피는 정적 검사와 단위, 통합, E2E 네 층으로 나누고 그중 통합 테스트를 가장 많이 씁니다. 정적 검사는 TypeScript와 ESLint가 맡고, 단위 테스트는 순수 함수와 유틸리티에, E2E는 중요한 시나리오에만 둬요.

SDK에서는 MediaDeviceManager와 ProtocolManager가 제대로 협력하는지, 에러가 났을 때 복구 로직이 동작하는지를 확인하는 통합 테스트가 가장 중요합니다.

만들고 나서 알게 된 것

추상화는 비용이다

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

같은 코드가 세 번째 나올 때 추상화를 고려합니다. 두 번까지는 중복을 그냥 두는 편이 나을 때가 많아요.

다만 이건 이미 쓴 코드에서 공통점을 뽑아낼 때 이야기예요. 앞의 BaseTransport는 구현이 둘뿐인데도 처음부터 인터페이스를 세웠어요. DataChannel과 MQTT 두 가지로 보낸다는 것을 시작할 때 이미 알고 있었거든요. 중복을 나중에 발견하는 것과 무엇이 필요한지 처음부터 아는 것은 다릅니다.

인터페이스가 구현보다 중요하다

SDK의 내부 구현은 얼마든지 바꿀 수 있지만, 공개 인터페이스는 한번 정하면 바꾸기 어려워요.

인터페이스를 설계할 때는 신중하게, 구현할 때는 빠르게 움직입니다.

에러 메시지는 사용자와의 소통이다

명확한 에러 메시지 하나가 고객사와 주고받을 문의를 줄여줍니다.

"에러가 발생했습니다"가 아니라 "카메라 권한을 허용해 주세요(40250)"처럼 무엇을 어떻게 하라는 말과 코드를 함께 적습니다.

마무리

당시 3년차 개발자가 처음 SDK를 설계하면서 겪은 시행착오와 고민입니다.

지금은 시중은행과 공공기관 여러 곳에 납품해 돌아가고 있어요. 은행 한 곳만 놓고 봐도 연동 개발사가 여섯 곳이 넘고, 그중에는 jQuery로 화면을 만든 곳도 있고 React로 만든 곳도 있습니다. 같은 SDK가 그 환경들에서 그대로 돌아요.

글 앞에서 JSP 개발자에게 기능을 건네줘야 했던 이야기를 했는데, 그 자리가 여기까지 왔습니다. 그때는 건네줄 물건 자체가 없었어요. 우리가 할 수 있는 말은 "우리 앱을 통째로 쓰세요"뿐이었습니다.

통신 기능에서 비롯된 장애 보고는 아직 없습니다. 다만 이건 설계가 좋았다는 증거라기보다, 화면을 SDK 밖에 둔 덕에 우리가 책임질 표면 자체가 좁아졌다고 보는 편이 맞을 거예요. 고칠 것이 적은 물건은 덜 깨집니다.

완벽한 설계는 없는 거 같습니다. 지금도 개선할 점이 많고, 앞으로도 계속 발전시켜 나갈 거예요. 하지만 처음부터 완벽을 추구했다면 아마 시작조차 못 했을 겁니다.

참고자료

댓글

스크롤하면 댓글이 로드됩니다.