Loki's Blog

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

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

프로젝트 소개


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

Plum은 비대면 강의의 단방향 소통 문제를 해결하기 위해 만든 실시간 화상 강의 서비스다. 녹화 영상이나 일방적인 송출로 진행되는 강의는 발표자와 참여자가 그 자리에서 서로 반응을 주고받기 어렵다. Plum은 그 자리에서 서로의 화면과 목소리를 실시간으로 주고받는 강의실을 만드는 걸 목표로 했다.

이를 위해 WebRTC 기반의 mediasoup를 사용했다. mediasoup는 여러 참여자의 미디어 스트림을 중계하는 SFU 서버로, 참여자마다 서로 P2P 연결을 맺는 대신 서버 하나를 거쳐 영상과 음성을 주고받게 해준다. 발표자가 화면을 공유하거나 카메라 / 마이크를 켜면 그 스트림이 mediasoup를 거쳐 강의실 안 모든 참여자에게 실시간으로 전달되고, 참여자도 같은 경로로 자신의 화면과 목소리를 발표자에게 보낼 수 있다.

문제 발견


토글 버튼 하나였던 게, 연결부터 상태 갱신까지 다 떠안고 있었다.

처음 화면 공유 / 카메라 / 마이크 토글을 만들 때는 버튼 하나에 로직을 다 담는 편이 자연스러웠다. 기능이 세 개뿐이었고, 켜고 끄는 흐름도 단순했다. 버튼 코드 하나만 열어보면 그 기능이 하는 일을 전부 알 수 있었다.

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

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

Notion image

해결 과정


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

기능별로 전담 모듈을 하나씩 두면 중복이 사라질 거라 생각했다.

처음에는 "기능별로 전담 모듈을 하나씩 만들면 되지 않을까?" 라고 생각했다. 화면 공유, 카메라, 마이크는 이미 UI에서도 서로 다른 버튼과 컴포넌트로 나뉘어 있었다. 그 경계를 로직에도 그대로 옮겨서 화면 공유 모듈은 화면 공유만, 카메라 모듈은 카메라만 책임지게 하면, 기능 하나에 문제가 생겼을 때 그 기능의 모듈 파일만 열어보면 된다는 게 매력적이었다.

Notion image
모듈 이름만 셋으로 늘었을 뿐, 연결 / 해제 로직은 그대로 반복됐다.

하지만 곧 한계가 보였다. 세 모듈을 나란히 열어보니 연결 채널을 여는 코드, 서버에 미디어 송출을 시작 / 중단하는 코드, 종료 시 전역 상태를 갱신하는 코드가 모듈마다 거의 그대로 반복돼 있었다. 세 기능은 "어떤 미디어를 다루는지"만 다를 뿐, 서버에 연결하고 끊는 흐름 자체는 동일했다.

화면 공유 모듈의 연결 코드를 고치면 카메라 / 마이크 모듈의 같은 코드도 똑같이 고쳐야 했고, 하나라도 빠뜨리면 그 기능만 종료 로직이 어긋난 채로 남았다. 모듈을 기능별로 나눠도 내부 로직은 결국 비슷한 형태로 반복될 수밖에 없었다. 이름만 다른 파일 세 개로 나뉘었을 뿐, 연결 / 해제 로직 중복은 그대로 남았다.

결국 문제는 모듈을 어떻게 나누느냐가 아니라, 연결하고 끊는 통신 로직 자체가 세 곳에 따로 존재한다는 데 있었다. 반복되는 통신 로직 자체를 공통 계층으로 끌어내는 접근이 필요했다.

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

통신 로직을 공통 계층 하나로 몰아넣고, 컴포넌트는 그 계층하고만 이야기하게 했다.

방향을 바꿔, 반복되는 통신 로직을 하나의 공통 계층으로 분리하고 그 위에 각 기능을 덧입히는 구조를 생각해 보았다. 앞서 세 모듈에 흩어져 있던 연결 채널 생성, 미디어 송출 시작 / 중단, 상태 갱신 코드를 전부 하나의 하위 계층으로 모으고, 그 하위 계층을 감싸는 중앙 제어 계층을 두었다.

화면 공유든 카메라든 마이크든 연결을 열고 닫는 절차는 이제 이 하위 계층 하나만 알면 됐다. 컴포넌트는 더 이상 통신 절차를 몰라도, 중앙 제어 계층과만 이야기하면 됐다.

Notion image

구조가 바뀌자 역할이 뚜렷해졌다. 화면 공유가 갑자기 끊기는 것처럼 연결 과정에서 문제가 생기면 공통 계층부터 확인하면 됐고, 버튼 위치나 아이콘처럼 화면에 보이는 부분이 어색하면 컴포넌트 쪽만 보면 됐다. 이전처럼 모듈 세 개를 한 번에 열어 어디가 문제인지 비교할 필요가 없어진 것이다. "어디를 고쳐야 하나"가 계층만 보고도 정해지는 구조였다.

공통 계층이 이번엔 통신 순서까지 전부 알아야 하는 계층이 됐다.

하지만 중앙 제어 계층에서 곧 새로운 문제가 보였다. 화면 공유 하나를 켜는 데도 중앙 제어 계층이 연결 모듈을 불러 채널을 열고, 송출 모듈을 불러 트랙을 실어 보내고, 수신 모듈의 상태를 구독해 전역 상태에 반영하는 세 단계를 순서대로 알고 있어야 했다.

모듈이 세 개면 순서도 세 가지를 외워야 했고, 기능이 하나 늘 때마다 이 순서를 기억하는 계층 하나가 함께 무거워졌다. 상태를 모아 보여주는 게 원래 이 계층의 역할이었는데, 그 위에 "무엇을 어떤 순서로 불러야 하나"라는 세부 통신 지식까지 얹힌 셈이었다.

부분적으로 로직을 모듈 안으로 옮겨봐도, 중앙 제어 계층이 그 모듈들을 정해진 순서로 호출해야 한다는 사실 자체는 달라지지 않았다. "이 순서를 아는 책임이 정말 이 계층에 있어야 하나?" 라는 질문이 계속 걸렸다.

"어디를 고쳐야 하나"는 여전히 계층으로 알 수 있었다. 하지만 그 계층 안에서 연결 / 송출 / 수신 중 어떤 모듈부터 들여다봐야 하는지는, 코드를 열어 호출 순서를 하나씩 따라가 봐야만 알 수 있었다.

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

OS 커널처럼, 세부 구현은 감추고 고수준 인터페이스만 남기자.

이번엔 세부 통신 로직 전체를 중앙 제어 계층 아래로 내려보내고, 중앙 제어 계층은 고수준 인터페이스만 바라보도록 만들었다. 연결 → 송출 → 수신을 어떤 순서로 불러야 하는지는 이제 인프라 계층 안에 캡슐화됐고, 중앙 제어 계층은 "화면 공유를 시작해줘" 같은 요청 하나만 던지면 나머지는 인프라 계층이 알아서 처리했다. 구체적인 연결 처리는 별도의 인프라 계층이 담당하고, 중앙 제어 계층은 그 결과만 받아 쓰는 구조다.

Notion image

OS 커널 개념이 여기서 잘 맞아떨어졌다. 애플리케이션이 파일을 열 때 디스크가 섹터를 어떻게 배치하는지 몰라도 open() 하나만 호출하면 되는 것처럼, 애플리케이션은 커널 내부를 몰라도 고수준 인터페이스만 알면 충분하다. 실시간 통신도 같은 방식으로 설계했다. 세부 구현은 하위 계층에 감추고, 상위 계층은 노출된 인터페이스만 호출하는 구조다.

덕분에 중앙 제어 계층은 연결 / 송출 / 수신의 호출 순서를 몰라도 되는, "상태를 제공하는 얇은 껍데기" 로 정리됐다. 컴포넌트에 필요한 상태를 모아 전달하는 일만 남았다. 문제가 생겼을 때도 중앙 제어 계층을 먼저 훑어보고 이상이 없으면 인프라 계층으로, 거기서도 답이 안 나오면 그 아래 실제 통신 코드로 내려가는 단계적 추적이 가능한 구조가 됐다.

숨겨놓은 자리에서 이번엔 통신과 상태 로직이 한 모듈 안에 뒤섞였다.

하지만 새로운 의문이 생겼다. 통신 로직 자체는 잘 숨겼지만, 이번엔 서로 다른 두 서버로 나가는 통신과, 그 응답으로 내부 상태를 바꾸는 로직이 한 모듈 안에 뒤섞여 있었다. 한 모듈이 두 서버와 각각 주고받는 통신과, 그 결과로 상태를 갱신하는 로직까지 함께 들고 있다 보니, 동작 하나가 실패했을 때도 원인을 가려내는 순서가 필요했다.

Notion image

어느 서버로 보낸 요청이 실패했는지부터 확인하고, 그다음에야 상태 갱신 코드로 넘어갈 수 있었다. 계층을 나눌수록 모듈 수도 함께 늘었고, 모듈 하나를 열어봐도 이 모듈이 통신까지 책임지는지 상태 갱신까지 책임지는지가 이름만으로는 구분되지 않았다. 내 머릿속엔 그 경계가 있었지만, 처음 보는 사람이 같은 코드를 봤을 때도 같은 경계가 보일 거라는 보장은 없었다.

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

통신 기술이 아니라, 어떤 서버와 이야기하는지를 기준으로 다시 나눴다.

지금까지는 연결 / 송출 / 수신처럼 통신 기술의 종류를 기준으로 계층을 나눴다면, 이번엔 "어떤 서버와 통신하나"를 기준으로 다시 나눴다. 서버별로 클라이언트를 두고, 그 위에 서비스 레이어를 올리는 구조다. 일반적인 HTTP 클라이언트 설계에서 흔히 쓰는 패턴을 실시간 통신에도 그대로 가져왔다. 통신과 상태 로직이 뒤섞이던 이전 문제도, 서버 단위로 클라이언트를 쪼개고 나니 어떤 서버와 주고받는 통신인지가 코드 위치만으로 구분됐다.

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

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

두 서버에 동시에 신호를 보내야 하는 상황도 서비스 레이어에서 묶어 처리했다. 예를 들어 강의실을 나갈 때는 참여자 목록을 관리하는 서버에 퇴장을 알리는 동시에, 미디어 연결을 정리하는 mediasoup 서버에도 연결 종료 신호를 보내야 했다. 앞서 세 번째 시도에서 걸렸던 것도 이 지점이었다. 한 모듈이 두 서버로 나가는 통신과 상태 갱신을 동시에 들고 있다 보니, 실패했을 때 어느 서버 문제인지부터 가려야 했다.

이제는 서비스 레이어의 메서드 하나가 두 클라이언트를 순서대로 호출하는 형태로 정리되면서, 상위 계층에서는 메서드 하나만 호출하면 양쪽에 신호가 전달된다. 두 서버 중 어느 쪽 요청이 실패했는지도 그 클라이언트의 응답만 보면 바로 알 수 있게 됐다.

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

최종 흐름은 UI → 서비스 → 클라이언트 → 서버 의 단방향 구조다. 화면이 갱신되지 않으면 UI를, 원하는 동작 자체가 호출되지 않으면 서비스 레이어를, 요청은 갔는데 응답이 없으면 클라이언트와 서버 사이 네트워크를 보면 됐다. 각 계층의 역할이 명확해지면서, 문제가 생겼을 때 UI인지, 서비스 로직인지, 네트워크인지를 계층을 따라 단계적으로 좁혀갈 수 있게 됐다.

Notion image

결론


로직 하나를 고칠 때 어디까지 번질지, 이제는 계층만 보고 가늠할 수 있다.

구조를 정리하고 나서 가장 먼저 달라진 건 변경의 영향 범위가 예측 가능해졌다는 점이다. 이전에는 로직 하나를 고치면 어디까지 파급될지 가늠하기 어려웠지만, 계층이 나뉘고 나서는 어느 계층의 문제인지만 파악하면 수정 범위가 자연스럽게 좁혀졌다.

새 기능을 추가할 때도 어느 계층에 무엇을 넣어야 할지 판단하는 데 드는 비용이 줄었다. 처음 발단이 됐던 화면 공유 종료 로직도, 이제는 세 버튼이 각자 로직을 들고 있는 대신 서비스 레이어의 메서드 하나를 호출하는 형태로 정리할 수 있는 구조가 됐다.

이 계층이 이 정보를 정말 알아야 하나?

이번 작업은 중복을 줄이려는 단순한 출발에서 시작했지만, 결국 이 질문을 반복해서 묻는 과정이었다. 공통 계층을 둘 때는 각 모듈의 내부 동작까지 중앙 계층이 알아야 하는지 물었다. 세부 구현을 아래로 내릴 때는 어떤 서버와 통신하는지까지 상위 계층이 알아야 하는지가 관건이었다. 서버별로 클라이언트를 나눌 때도 두 서버에 보내는 신호를 상위 계층이 직접 구분해야 하는지가 같은 자리에서 다시 걸렸다. 매 단계 몰라도 되는 정보는 그 계층에서 걷어내야 했다.

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

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

관련 글