Loki 's Blog

실시간 통신 로직을 계층으로 정리하기

  • Mediasoup
  • WebSocket
  • 리팩토링
2026. 02. 07.
Notion image

프로젝트 소개


어떤 프로젝트를 진행하고 있었는가?

Plum은 비대면 강의의 단방향 소통 문제를 해결하기 위해 만든 실시간 화상 강의 서비스다. WebRTC 기반의 mediasoup를 사용해 발표자와 참여자가 실시간으로 영상과 음성을 주고받을 수 있도록 구현했다.

문제 발견


어떤 문제를 발견했는가?

개발하면서 한 가지 불편함이 쌓이기 시작했다. 화면 공유, 카메라, 마이크처럼 겉으로는 단순한 토글 하나가, 실제로는 연결 채널 생성, 미디어 송출, 서버 통신, 전역 상태 갱신까지 한꺼번에 처리하고 있었다.

기능이 추가될수록 비슷한 로직이 여러 곳에 흩어졌고, 무언가를 고쳐야 할 때 어디서부터 시작해야 할지 가늠하기 어려워졌다. 특히 화면 공유 기능 하나만 봐도, 서로 다른 세 곳의 버튼이 거의 동일한 종료 로직을 각자 들고 있었다. 하나를 고치면 나머지도 찾아가야 하는, 실수가 쌓이기 쉬운 구조였다.

Notion image

해결 과정


첫 번째 시도: 기능별 전담 모듈

어떤 방식으로 해결하고자 했는가?

처음에는 "기능별로 전담 모듈을 하나씩 만들면 되지 않을까?" 라고 생각했다. 화면 공유, 카메라, 마이크 각각에 맞는 모듈을 두면 중복이 사라질 것처럼 보였다.

Notion image
어떤 한계점이 존재했는가?

하지만 곧 한계가 보였다. 세 기능은 "어떤 미디어를 다루는지"만 다를 뿐, 서버에 연결하고 끊는 흐름 자체는 동일했다. 모듈을 기능별로 나눠도 내부 로직은 결국 비슷한 형태로 반복될 수밖에 없었다. 이름만 다른 파일을 만든 것이지, 구조적으로 중복을 해결한 게 아니었다.

필요한 건 기능 단위 분리가 아니라, 반복되는 통신 로직 자체를 공통 계층으로 끌어내는 접근이었다.

두 번째 시도: 공통 계층 도입

어떤 방식으로 해결하고자 했는가?

방향을 바꿔, 반복되는 통신 로직을 하나의 공통 계층으로 분리하고 그 위에 각 기능을 덧입히는 구조를 생각해 보았다. 통신 관련 로직 전체를 하위 계층에 모으고, 그것을 감싸는 중앙 제어 계층을 두었다. 컴포넌트는 이 중앙 제어 계층과만 이야기하면 됐다.

Notion image

구조가 바뀌자 역할이 뚜렷해졌다. 연결 과정에서 문제가 생기면 공통 계층을 먼저 확인하고, UI가 어색하면 컴포넌트 쪽을 보면 됐다. "어디를 고쳐야 하는가"가 계층에 의해 자연스럽게 결정되는 구조였다.

어떤 한계점이 존재했는가?

하지만 중앙 제어 계층에서 곧 새로운 문제가 보였다. 연결, 송출, 수신을 담당하는 각 모듈을 하나씩 직접 불러와 호출하는 구조가 되어 있었다. 중앙 제어 계층이 알아야 할 것도 함께 늘었고, 상태 공유라는 본래 역할 위에 세부 통신 구현 지식까지 쌓이면서 계층은 점점 비대해졌다.

부분적으로 로직을 분리해봐도 같은 계층 안에 머물러 있다는 사실은 달라지지 않았다. "이 책임이 정말 이 계층 안에 있어야 하나?" 라는 질문이 자꾸 걸렸다.

세번째 시도: 세부 구현을 계층 아래로 숨기기

어떤 방식으로 해결하고자 했는가?

이번엔 세부 통신 로직 전체를 중앙 제어 계층 아래로 내려보내고, 중앙 제어 계층은 고수준 인터페이스만 바라보도록 만들었다. 구체적인 연결 처리는 별도의 인프라 계층이 담당하고, 중앙 제어 계층은 그 결과만 받아 쓰는 구조다.

Notion image

OS 커널 개념이 여기서 잘 맞아떨어졌다. 애플리케이션은 커널 내부를 몰라도 된다. 고수준 인터페이스만 알면 충분하다. 실시간 통신도 같은 방식으로 설계했다. 세부 구현은 하위 계층에 감추고, 상위 계층은 노출된 인터페이스만 호출하는 구조다.

덕분에 중앙 제어 계층은 "상태를 제공하는 얇은 껍데기" 로 정리됐다. 문제가 생겼을 때도 인프라 계층부터 살펴보고, 필요하면 더 아래로 내려가는 단계적 추적이 가능한 구조가 됐다.

어떤 한계점이 존재했는가?

하지만 새로운 의문이 생겼다. 통신 로직은 잘 숨겨졌지만, 이번엔 두 종류의 서버 통신과 내부 상태 로직이 한 흐름 안에 섞여 있었다. 계층을 나눌수록 모듈 수도 늘어났고, "이 모듈이 어디까지 책임지는가" 가 코드만 봐서는 직관적으로 파악되지 않았다. 내 머릿속엔 맥락이 있었지만, 처음 보는 사람에게는 그렇지 않을 터였다.

Notion image

마지막 시도: 통신 대상 기준으로 계층 나누기

어떤 방식으로 해결하고자 했는가?

이번엔 기술 스택이 아니라 "어떤 서버와 통신하는가"를 기준으로 계층을 나눴다. 서버별로 클라이언트를 두고, 그 위에 서비스 레이어를 올리는 구조다. 일반적인 HTTP 클라이언트 설계에서 흔히 쓰는 패턴을 실시간 통신에도 그대로 가져왔다.

클라이언트는 "어떤 서버와 어떻게 통신하는가" 만 담당한다. 서비스 레이어는 그 위에서 도메인 행동을 명시적인 메서드로 정의한다. 강의실 입장, 퇴장, 종료 같은 행동이 각각 하나의 메서드로 표현되는 식이다. 상위 계층은 통신 세부 구현을 몰라도, 서비스 메서드를 호출하는 것만으로 필요한 동작을 수행할 수 있다.

어떤 서버와 통신하는지에 따른 Client 구상
어떤 서버와 통신하는지에 따른 Client 구상

두 서버에 동시에 신호를 보내야 하는 상황도 서비스 레이어에서 묶어 처리했다. 상위 계층에서는 메서드 하나만 호출하면 양쪽에 신호가 전달된다.

Service를 호출하면, Client를 통해 서버와 통신
Service를 호출하면, Client를 통해 서버와 통신

최종 흐름은 UI → 서비스 → 클라이언트 → 서버 의 단방향 구조다. 각 계층의 역할이 명확해지면서, 문제가 생겼을 때 UI인지, 서비스 로직인지, 네트워크인지를 계층을 따라 단계적으로 좁혀갈 수 있게 됐다.

Notion image

결론


결과적으로 얼마나 개선됐는가?

구조를 정리하고 나서 가장 먼저 달라진 건 변경의 영향 범위가 예측 가능해졌다는 점이다. 이전에는 로직 하나를 고치면 어디까지 파급될지 가늠하기 어려웠지만, 계층이 나뉘고 나서는 어느 계층의 문제인지만 파악하면 수정 범위가 자연스럽게 좁혀졌다. 새 기능을 추가할 때도 어느 계층에 무엇을 넣어야 할지 판단하는 데 드는 비용이 줄었다.

구조 설계에서 중요한 것

이번 작업은 중복을 줄이려는 단순한 출발에서 시작했지만, 결국 "이 계층이 이 정보를 정말 알아야 하는가" 를 반복해서 묻는 과정이었다. 공통 계층을 두고, 세부 로직을 숨기고, 서버별로 역할을 나누는 매 단계마다 그 질문이 기준이 됐다.

내 머릿속에만 있는 맥락은 구조가 아니라는 점을 경험할 수 있었다. 통신과 도메인 로직이 섞이고 모듈이 많아질수록, 코드를 작성한 나조차 시간이 지나면 다시 파악하는 데 비용이 든다. 좋은 구조는 처음 보는 사람이 어디서부터 읽어야 할지 알 수 있는 구조라는 걸 느꼈다.

뒤늦게 고치다 보니 기대만큼 효과를 내지 못한 부분도 있었다. 다음엔 이 질문들을 개발 초기부터 꺼내 놓으려 한다.

관련 글