프론트엔드 Mediasoup 계층 구조 다시 설계하기
- Mediasoup
- WebSocket
- 리팩토링

들어가며
Plum 이라는 화상 강의를 위한 실시간 연결을 구현하면서, mediasoup 위에 쌓인 여러 기능들이 생각보다 빠르게 복잡해지고 있다는 느낌을 받았다. 화면 공유, 카메라, 마이크처럼 겉으로 보기엔 단순한 토글 기능들이 실제 코드에서는 transport 생성, media 송출, 서버와의 통신, 전역 상태 업데이트까지 모두 한 번에 끌어안고 있었다.
기능이 하나씩 추가될수록 비슷한 로직이 여러 곳에 흩어지고, 어디를 고쳐야 할지 한눈에 보이지 않는 순간들이 많아졌다. 그 과정에서 mediasoup 관련 로직을 어떻게 나누고, 어떤 계층으로 숨기고, 어떤 구조로 정리해야하는지 고민을 많이 했다.
초기 설계: 필요한 기능에 맞는 설계
어떤 과정을 진행 중이었는가?
화상 연결 기능을 구현하면서 mediasoup 로직을 통해 서버와 연결하는 부분을 다루고 있었다. 이때 연결 통로가 되는 transport 생성과 media를 송출하는 produce 과정이 여러 위치에서 반복되고 있다는 점이 눈에 들어왔다.

특히 화면 공유 기능에서는 상단의 화면 공유 중지 버튼, 하단의 화면 공유 토글 버튼, 브라우저 자체의 화면 공유 중지 버튼까지 세 곳에서 거의 동일한 종료, 중지 로직이 쓰이고 있었다. 이런 구조는 시간이 지날수록 변경 포인트를 늘리고, 실수를 유발하기 쉬운 형태라는 생각이 들었다.

그렇다면 이를 하나의 훅으로 만들어서 담당하자.
처음에는 이 중복을 줄이기 위해 “화면 공유 중지 전용 훅을 하나 만들면 되지 않을까?”라는 접근을 했다. 공통 훅만 잘 만들어 두면, 화면 공유를 중지해야 하는 어느 지점에서든 그 훅을 호출하는 것으로 문제를 해결할 수 있을 것처럼 보였다.
하지만, 곧 카메라, 마이크 기능이 떠올랐다. 이들도 결국 “어떤 미디어를 송출할지 결정하고, 어떤 상태를 갱신할지”만 다를 뿐, 서버에 미디어를 보내고 끊는 과정 자체는 화면 공유와 동일한 패턴을 공유하고 있었다.
왜 중복 / 일관성 문제를 다 해결하지 못했는가
여기서 생각을 한 번 더 확장해 보니, 화면 공유, 카메라, 마이크마다 별도의 훅을 만드는 방식은 겉으로만 중복을 줄인 것처럼 보일 수 있다는 의심이 들었다. 연결과 송출을 담당하는 내부 로직은 여전히 거의 같은 형태로 여러 훅에 반복될 가능성이 컸기 때문이다.
그렇게 되면 처음에 문제로 삼았던 “로직 중복”과 “동일한 동작에 대한 일관성 유지”를 구조적으로 해결하지 못한 채, 단지 파일과 훅 이름만 나눈 셈이 된다. 결국 이 방식으로는 내가 기대한 수준의 일관성과 변경 용이성을 확보하기 어렵겠다는 한계를 분명하게 느끼게 되었다.
1. MediaConnectionProvider 의 도입
연결 과정을 공통 계층으로 만든다면?
중복된 훅만으로는 충분하지 않다는 결론에 이르자, mediasoup에 직접적으로 관련된 로직들(transport 생성, producer나 consumer를 다루는 부분)을 하나의 공통 계층으로 분리해 두고 그 위에 카메라, 마이크, 화면 공유 기능을 덧입히는 구조를 상상해 보았다.
OS 커널/시스템 콜 비유
이때 과거에 운영체제를 직접 구현해보던 경험이 떠올랐다. OS의 커널에서 실행되는 로직은 사용자 공간에서 직접 알 필요가 없고, 사용자 영역에서는 단지 고수준의 API나 시스템 콜을 통해 필요한 기능만 요청한다는 개념이다.
이 비유를 mediasoup에 그대로 가져오면, mediasoup 관련 로직을 커널처럼 하위 계층에 두고, 컴포넌트는 이를 시스템 콜처럼 호출하는 고수준 API(MediaConnectionProvider)만 사용하도록 만들 수 있다.
useMediaTransport / useMediaProducer / useMediaConsumer 구조
이 아이디어를 바탕으로, 나는 mediasoup 로직을 담당하는 훅들을 하위 계층에 배치했다. 그리고 그 위에 이들을 감싸는 중앙 제어 계층(MediaConnectionProvider)과 실제 UI 컴포넌트를 올려두는 형태로 구조를 설계했다.
변경 전에는 컴포넌트가 mediasoup 세부 로직에 훨씬 더 가까이 붙어 있었지만, 변경 후에는 “컴포넌트 → 중앙 제어 계층 → mediasoup 훅”으로 단계를 분리할 수 있었다.

이렇게 했을 때 얻은 장점
이 구조로 바꾸고 나서 가장 먼저 느낀 장점은 역할과 책임이 훨씬 명확해졌다는 점이다. mediasoup를 통한 연결이나 송출 과정에서 문제가 생기면 공통 계층의 로직을 우선 확인하면 되고, 화면에 어떻게 보여줄지와 같은 부분은 상위 API와 컴포넌트에서만 고민하면 되었다.
3. 기능을 숨기자: Provider 비대화와 useMediaInfra
MediaConnectionProvider 에 몰린 과도한 책임
공통 계층을 두면서 구조는 한 번 정리됐지만, 곧 MediaConnectionProvider 쪽에서 또 다른 냄새가 나기 시작했다. mediasoup 관련 로직이 여전히 바깥으로 많이 노출되어 있었고, Provider 내부에 통신 제어와 전역 스토어 제어 같은 상위 레벨 로직이 함께 섞여 있었다.
Provider가 “연결 상태를 공유하는 컨텍스트”를 넘어서, mediasoup 내부 구현까지 자세히 알고 있어야 하는 구조가 과연 맞는가 하는 의문이 들었다. 자연스럽게 Provider 코드의 크기는 커졌고, transport, producer, consumer를 다루는 코드와 각종 존재 여부 체크 로직이 곳곳에 반복되었다.
mediasoup 로직을 Context 바깥으로 숨기기
체크 로직을 별도 함수로 분리해도, 여전히 같은 Provider 내부에 몰려 있다는 점은 바뀌지 않았다. “이 책임이 정말 Provider 안에 있어야 하나?”라는 질문에 선뜻 그렇다고 답하기 어려웠다.
그래서 이번에는 아예 mediasoup 로직을 컨텍스트 바깥으로 숨기는 방향으로 생각을 전환했다. Context는 단지 연결 여부나 고수준 이벤트만 알고, 구체적인 연결 과정과 처리 로직은 보지 않도록 하는 것이 목표였다.
useMediaInfra로 transport/producer/consumer/device를 조합
이를 위해 transport, producer, consumer, device를 연결하고 가져오는 과정을 useMediaInfra라는 하나의 훅 안에서 조합하도록 설계했다. MediaConnectionProvider에서는 이 useMediaInfra만 의존하면 되고, 그 내부에서 어떤 순서로 어떤 mediasoup 훅을 쓰는지는 숨겨진다.
통신 흐름에 문제가 생겼을 때도, 먼저 useMediaInfra를 살펴보고, 필요하다면 그 아래에 있는 개별 훅들로 내려가면 되는 단계적인 구조를 만들 수 있었다.

이 구조의 장점과 여전히 남은 아쉬움
이렇게 구조를 바꾸자, MediaConnectionProvider가 감당하던 책임 상당 부분이 useMediaInfra와 그 하위 계층으로 내려갔다. Provider는 본연의 역할에 가까운 “상태를 제공하는 얇은 껍데기”에 더 가깝게 정리되었고, mediasoup 관련 세부 구현은 별도의 인프라 훅에서 관리할 수 있었다. 다만 이 시점에서 훅의 개수가 꽤 많아지면서, “이 정도까지 쪼개는 것이 과연 최선인가?”라는 새로운 의문도 함께 남게 되었다.
4. 통신 계층의 분리: Client와 Service 레이어의 도입
훅이 과하게 많아진 문제, 책임이 섞인 문제
useMediaInfra까지 도입하고 나니 mediasoup 로직은 꽤 잘 숨겨졌지만, 다른 종류의 복잡도가 눈에 들어왔다. 훅의 개수가 계속 늘어나면서 서로 밀접하게 연관된 훅들이 과하게 쪼개져 있다는 느낌이 들었다.
특히 mediasoup 서버와의 통신, 백엔드 서버와의 통신, 내부 상태 로직이 한 코드 흐름 안에 섞여 있어 “각 훅이 정확히 어디까지 책임져야 하는가”를 직관적으로 파악하기 어려웠다. 내 머릿속에서는 어느 정도 맥락이 잡혀 있었지만, 다른 사람이 이 코드를 처음 봤을 때 같은 수준으로 이해해 줄 것 같지는 않았다.
‘서버와의 통신’이라는 공통점에 집중
그래서 다시 한 번 관점을 바꾸기로 했다. 이번에는 mediasoup이냐, 백엔드 서버냐를 나누기보다 “결국 특정 서버와 통신한다”는 공통점에 집중해 보기로 했다. 일반적인 HTTP 통신 로직처럼, 목표 서버별로 client를 두고, 그 위에 서비스 레이어를 올리는 구조를 WebSocket, mediasoup 환경에도 그대로 가져와 보자는 아이디어였다.
서버별 Client 분리
먼저 각 서버에 대한 통신을 담당하는 client를 분리했다. “어떤 서버와 어떤 프로토콜로 이야기하는지”를 기준으로 client를 나누었다. 이렇게 하자 네트워크 레벨에서 일어나는 일과 도메인 레벨에서 필요한 동작을 자연스럽게 분리할 수 있었다.

Service 레이어 도입
그다음 이 client들을 감싸는 서비스 레이어를 두었다. 예를 들어, RoomService는 “강의실 입장/퇴장, 종료, 프레젠테이션 정보 조회, 방 관련 이벤트 리스너 등록, 해제”라는 도메인 행동을 메서드 단위로 정의한다.
export class RoomService {
private static unsubscribers: (() => void)[] = [];
/** 강의실 입장 알림 전송 */
static async joinRoom(payload: JoinRoomRequest) {
return await SocketClient.emitWithAck('join_room', payload);
}
/** 강의실 퇴장 알림 전송 */
static async leaveRoom() {
return await SocketClient.emitWithAck('leave_room');
}
/** 강의실 종료 알림 전송 */
static async breakRoom() {
return await SocketClient.emitWithAck('break_room');
}
/** 현재 강의실의 프레젠테이션 정보 조회 */
static async getPresentation() {
return await SocketClient.emitWithAck('get_presentation');
}
/** 방 관련 이벤트 리스너 등록 */
static async setupEventHandlers(handlers: RoomEventHandlers) {
this.removeEventHandlers();
const results = await Promise.all([
SocketClient.on('user_joined', handlers.onUserJoined),
SocketClient.on('user_left', handlers.onUserLeft),
SocketClient.on('room_end', handlers.onRoomEnd),
SocketClient.on('speaker_detected', handlers.onSpeakerDetected),
]);
this.unsubscribers = results;
}
/** 방 관련 이벤트 리스너 해제 */
static removeEventHandlers() {
if (this.unsubscribers.length === 0) return;
this.unsubscribers.forEach((unsub) => unsub());
this.unsubscribers = [];
}
}내부에서는 SocketClient의 emitWithAck나 on을 사용하지만, 밖에서 보기에는 “joinRoom, leaveRoom, breakRoom, getPresentation, setupEventHandlers” 같은 명시적인 도메인 API로만 보이도록 만들었다. 이제 훅이나 컴포넌트는 SocketClient의 세부 구현을 몰라도, RoomService의 메서드를 호출하는 것만으로 필요한 기능을 사용할 수 있게 되었다.
mediasoupService, MediaConnectionService로 역할 재구성
mediasoup 쪽에서도 마찬가지로, 백엔드 서버에 상태를 알려야 하는 부분은 mediasoupService를 통해 백엔드 소켓 서버와 통신하도록 했다. 실제 mediasoup 서버와의 통신은 MediasoupClient가 담당하고, 두 종류의 통신을 함께 다뤄야 하는 구간은 MediaConnectionService라는 중간 레이어에서 묶어주도록 설계했다.
훅에서는 MediaConnectionService만 호출하면 mediasoup 서버와 백엔드 서버 양쪽에 필요한 시그널이 전달되므로, 네트워크 세부사항을 직접 신경 쓸 필요가 없다.

최종 구조
최종적으로는 컴포넌트 / 훅 → service → client → 각 서버 라는 단방향 흐름이 만들어졌다. 여기에 전역 상태 store와 하드웨어 제어 계층을 얹으면 강의실 페이지의 구조가 완성된다. 컴포넌트와 훅은 service와 store에만 의존하고, service는 각 client를 통해 서버와 통신하는 구조가 된다.
이렇게 역할을 나누자 각 레이어가 무엇을 책임지는지가 훨씬 분명해졌고, 특정 문제를 추적할 때도 “UI 문제인지, 서비스 로직 문제인지, 네트워크/서버 통신 문제인지”를 단계적으로 좁혀갈 수 있게 되었다.

정리하며
이번 구조 개선 과정은 단순히 중복된 코드를 줄이자 에서 시작했지만, 결국에는 무엇을 어디까지 책임지게 할 것인지에 대한 고민으로 이어졌다. mediasoup 계층, useMediaInfra, Client/Service 레이어를 하나씩 도입해 가면서, 기능을 추가하기 위한 구조가 아니라 변경이 용이하고 다른 사람들이 쉽게 이해할 수 있는 구조가 무엇인지 계속 질문하게 되었다.
“이 레이어가 정말 이 정보를 알아야 하는가?”, “문제가 생겼을 때 어디부터 따라가면 되는가?” 같은 질문이 설계 기준이 되었다. 내 머릿속에만 존재하는 맥락에 의존한 구조는 팀 단위로는 오래 버티기 어렵다는 것도 함께 느꼈다. 훅이 많아지거나, 통신과 도메인 로직이 섞이기 시작하면, 작성자인 나조차 시간이 지나면 다시 이해하는 데 비용이 든다.
그래서 이번에는 처음부터 “다른 사람이 이 코드를 봤을 때, 어디서부터 읽어 내려가야 할까?”를 계속 염두에 두며 레이어를 나누고 이름을 정했다. 앞으로는 이런 구조적 고민을 개발 초기에 먼저 풀어가려 한다. 뒤늦게 개선하니 기대만큼의 효과를 내지 못한 아쉬움도 있지만, 이번 경험을 통해 구조 설계에서 진짜 중요한 것이 무엇인지 깨달았다. 다음 개발에서는 훨씬 더 수월하게 시작할 수 있을 것 같다.
