본문 바로가기
Framework/Spring Boot

[Spring Boot] WebRTC 화상 회의 P2P 시그널링 서버

여러분이 당근마켓의 개발자인데, 사용자들이 중고 물품을 실시간으로 보여주며 대화할 수 있는 '1:1 영상 통화' 기능을 만들어 달라는 미션을 받았습니다. 일반적인 생각으로는 철수의 스마트폰 카메라(영상) 데이터를 스프링 부트 서버로 쏘고, 서버가 그걸 다시 영희에게 쏴주는(Relay) 방식을 떠올립니다. 하지만 영상 데이터는 1초에 수십 메가바이트(MB)를 차지합니다. 1,000명이 동시에 영상 통화를 한다면 서버의 네트워크 대역폭(Bandwidth) 요금만 한 달에 수억 원이 청구될 것이며 서버는 펑 하고 터질 것입니다. "서버를 거치지 않고, 철수 폰에서 영희 폰으로 직접(Peer-to-Peer, P2P) 영상 데이터를 쏴버릴 수는 없을까?" 이 미친 효율의 혁신적인 기술이 바로 웹 브라우저 간의 다이렉트 통신 표준, WebRTC(Web Real-Time Communication)입니다.

이번 글에서는 "줌(Zoom)과 구글 미트의 심장!" WebRTC의 핵심 원리와, 두 사람을 만나게 해주는 스프링 부트 시그널링(Signaling) 서버 구축에 대하여 다뤄보겠습니다.

1. WebRTC의 함정: P2P 연결을 위해선 '중매쟁이'가 필요하다

WebRTC는 100% P2P 통신입니다. 하지만 치명적인 모순이 하나 있습니다. 철수가 영희에게 영상 데이터를 쏘려면 '영희의 현재 IP 주소(집 주소)'를 알아야 합니다. 철수는 영희의 IP를 어떻게 알 수 있을까요? 텔레파시로 알 수는 없으니, 결국 처음 서로의 주소(명함)를 교환할 때는 반드시 한 번은 중앙 서버를 거쳐야 합니다.
이 "명함을 교환해 주는 중매쟁이 서버"를 시그널링 서버(Signaling Server)라고 부릅니다.

교환하는 명함(데이터)은 딱 2가지 텍스트입니다.

  • SDP (Session Description Protocol): "내 카메라는 해상도가 1080p고 음성 코덱은 Opus를 써." (미디어 스펙 교환)
  • ICE Candidate: "내 공유기 외부 IP 주소는 220.123.45.67 이야. 일로 데이터 쏴줘!" (네트워크 경로 교환)

 

2. Spring Boot 시그널링 서버 구축 (WebSocket)

시그널링 서버는 텍스트(SDP, ICE)를 실시간으로 빠르게 전달해주기만 하면 됩니다. 그래서 에서 배웠던 WebSocket이 시그널링 서버로 가장 완벽한 기술입니다. 프론트엔드가 생성한 텍스트 덩어리를 받아 반대편 브라우저로 토스만 해주는 아주 단순한 핸들러를 짭니다.

// WebRTC 시그널링(명함 교환)을 위한 스프링 부트 순수 웹소켓 핸들러
@Component
@Slf4
public class SignalingWebSocketHandler extends TextWebSocketHandler {

    // 현재 화상 회의방에 들어온 세션들 보관 (실무에서는 방 번호별 Map으로 관리해야 함)
    private final List<WebSocketSession> sessions = new CopyOnWriteArrayList<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session); // 접속
    }

    // 핵심: 철수가 SDP나 ICE Candidate 텍스트(JSON)를 보내면, 내가(서버가) 읽어보고 영희한테 그대로 던져준다!
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        log.info("시그널링 메시지 도착: {}", message.getPayload());

        // 나(보낸 사람)를 제외한 나머지 방 안의 사람들에게 명함(메시지) 전달!
        for (WebSocketSession connectedSession : sessions) {
            if (!connectedSession.getId().equals(session.getId())) {
                connectedSession.sendMessage(message);
            }
        }
    }
}

백엔드의 역할은 정말 이게 끝입니다! 이 텍스트 명함을 주고받은 이후부터 프론트엔드(JavaScript)의 RTCPeerConnection 객체가 각자의 카메라를 켜고 마법처럼 P2P로 영상 스트리밍을 시작합니다. 서버는 어떠한 미디어 트래픽도 감당하지 않으므로 부하가 0에 가깝습니다.

 

3. 실무의 장벽: TURN 서버 (방화벽의 늪)

"완벽하네요! 당장 출시합시다!" 하지만 실무(LTE, 회사 망) 환경에 앱을 배포하면 10번 중 3번은 영상이 까맣게 나오며 연결이 안 됩니다. 왜 그럴까요? 바로 방화벽과 NAT(공유기) 때문입니다. 회사나 카페의 엄격한 공유기는 허락되지 않은 외부 IP(영희 폰)에서 다이렉트로 들어오는 P2P 트래픽을 바이러스로 간주하고 다 차단해 버립니다.

이 30%의 연결 실패를 구제하기 위해, 결국 P2P를 포기하고 "서버가 영상을 릴레이(중계) 해주는" 우회 서버를 두게 됩니다. 이것을 TURN 서버(Coturn 등)라고 부릅니다. 100% P2P 무비용을 꿈꿨지만, 결국 서비스의 안정성을 위해 별도의 미디어 중계 서버 인프라 비용을 어느 정도 감수해야 하는 것이 WebRTC 실무의 냉혹한 현실입니다.

 

4. 마무리 및 주요 발전 방향

지금까지 어마어마한 영상 트래픽을 서버가 부담하지 않고 클라이언트끼리 직접 주고받게(P2P) 만드는 천재적인 아키텍처 WebRTC의 원리와, 이 둘의 IP를 최초 교환해 주는 백엔드의 중매쟁이 역할(Spring WebSocket Signaling)에 대해 상세히 다루어 보았습니다. 이제 여러분의 이력서에 '실시간 화상 커뮤니케이션 서버 구축'이라는 화려한 스펙이 추가되었습니다.

지금까지 다양한 기술(JPA, Redis, Kafka, WebRTC 등)을 단일 스프링 부트 프로젝트(모놀리식)에 모두 집약하면서 프로젝트가 비대해졌습니다. 소스코드 폴더 안에는 웹 로직, 배치 로직, 도메인 엔티티가 난장판으로 뒤섞여 "엔티티 하나 고쳤더니 배치 서버가 빌드 에러를 내뿜는" 끔찍한 스파게티 의존성 지옥이 펼쳐집니다. "API 서버, 관리자 서버, 배치 서버가 똑같은 JPA 엔티티(User, Order)를 써야 하는데, 코드를 복사 붙여넣기 하지 않고 깔끔하게 '공통 모듈'로 빼서 레고 블록처럼 조립할 순 없을까?" 본 설명에서는 "거대해진 프로젝트의 우아한 독립!" Gradle 멀티 모듈(Multi-Module) 아키텍처를 통한 도메인 주도 설계(DDD)와 의존성 분리 실무 가이드에 대해 아주 심도 있게 살펴보겠습니다.


작은 쇼핑몰로 시작했던 우리의 스프링 부트 프로젝트가 3년 만에 거대해졌습니다. 처음에는 src/main/java 폴더 하나에 컨트롤러(API), 도메인(Entity), 그리고 새벽에 도는 배치(Batch) 코드까지 한 프로젝트에 모두 포함하고 행복하게 개발했습니다. 그런데 어느 날, 배치 팀 개발자가 User 엔티티에 필드를 하나 추가하고 배포를 했더니, 갑자기 API 팀의 로그인 서버가 런타임 에러를 뿜으며 죽어버립니다. "아니, 나는 배치 코드만 고쳤는데 왜 API 서버가 죽어?!" 하나의 거대한 프로젝트(모놀리식) 안에서 모든 코드가 뒤엉켜버린 스파게티 의존성의 저주입니다. 그렇다고 당장 에서 배운 MSA(마이크로서비스)로 완전히 찢자니 서버 인프라 비용과 관리의 압박이 너무 심합니다. "하나의 프로젝트(레포지토리) 안에서, 공통으로 쓰는 핵심 도메인만 따로 떼어내고, API와 배치는 서로의 코드를 절대 못 보게 격리(분리)할 수는 없을까?" 이 우아한 절충안이 바로 Gradle 멀티 모듈(Multi-Module) 아키텍처입니다.

본 글에서는 "스타트업에서 중견기업으로 넘어가는 필수 관문!" 멀티 모듈 설계와 의존성 주입 실무 가이드에 대하여 다뤄보겠습니다.

1. 스파게티 코드의 종식: 역할별 모듈 분리

스프링 부트 프로젝트 최상단 폴더에 3개의 하위 폴더(모듈)를 새로 만듭니다.

  • module-core (코어 모듈): 모든 서버가 공통으로 알아야 하는 JPA 엔티티(User, Product 등), 공통 예외 처리, 공통 유틸리티 클래스만 들어갑니다. 이 모듈은 다른 어떤 모듈도 쳐다보지(의존하지) 않습니다. 오직 순수한 비즈니스 로직만 가집니다.
  • module-api (API 서버 모듈): 프론트엔드와 통신하는 @RestController들이 들어갑니다. Tomcat을 띄우는 메인 실행 클래스가 여기 있습니다. 이 모듈은 오직 module-core만 가져다 씁니다(의존).
  • module-batch (배치 서버 모듈): 새벽에 도는 대용량 배치(Job, Step) 로직만 들어갑니다. 당연히 module-core만 가져다 씁니다.

가장 중요한 핵심은 "module-api와 module-batch는 서로의 존재를 모른다(의존성 없음)"는 것입니다. 따라서 배치 개발자가 module-batch에서 아무리 똥을 싸도, API 서버는 단 1%의 영향도 받지 않고 평화롭게 돌아갑니다!


2. Gradle 설정: 의존성(Dependency) 조립하기

루트 폴더의 settings.gradle과 build.gradle을 수정하여 모듈 간의 계급과 관계를 정의합니다.

// settings.gradle (루트)
rootProject.name = 'my-shopping-mall'
include 'module-core', 'module-api', 'module-batch'
// build.gradle (module-api 폴더 내부)
dependencies {
    //  핵심: API 모듈은 Core 모듈을 자신의 프로젝트 내부 라이브러리처럼 땡겨옵니다!
    implementation project(':module-core')
    
    // API 서버 구동을 위한 웹 의존성 추가
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

이제 API 모듈에서 module-core에 만들어둔 User.java 엔티티를 import하여 자연스럽게 사용할 수 있습니다.


3. 멀티 모듈의 함정: Bean 스캔 지옥

설정을 끝내고 module-api를 실행했는데 "User 엔티티를 못 찾겠어!" 또는 "Core에 있는 빈(Bean) 주입 실패!" 에러가 터집니다. 왜 그럴까요?
스프링 부트의 @SpringBootApplication은 기본적으로 자기가 속한 패키지(예: com.myapp.api)와 그 하위만 뒤져서 빈을 등록합니다. 그런데 Core 모듈의 패키지가 com.myapp.core라면? 스프링이 Core 쪽은 아예 쳐다보지도 않게 됩니다.

해결책: API 모듈의 메인 클래스에서 빈 스캔 범위를 최상위 공통 패키지(com.myapp)로 강제로 넓혀주어야 합니다.

// module-api 의 메인 클래스
@SpringBootApplication(scanBasePackages = {"com.myapp.core", "com.myapp.api"})
@EntityScan(basePackages = "com.myapp.core") // JPA 엔티티 스캔 범위 지정
@EnableJpaRepositories(basePackages = "com.myapp.core") // Repository 스캔 범위 지정
public class ApiApplication { ... }

4. 마무리 및 주요 발전 방향

지금까지 거대한 하나의 덩어리를 우아한 레고 블록 단위로 찢어내고, 팀별(API팀, 배치팀)로 서로 간섭 없이 독립적인 개발을 가능하게 만드는 MSA 이전 단계의 필수 클린 아키텍처, Gradle 멀티 모듈 설계에 대해 상세히 다루어 보았습니다. 이제 소스코드를 고칠 때 덜덜 떨지 않아도 됩니다.

백엔드 소스코드는 모듈로 예쁘게 분리했습니다. 그런데 데이터베이스(DB)는 어떨까요? 개발 서버에서는 컬럼 이름이 user_name인데, 운영 서버에서는 name으로 되어 있어서 오늘 밤 배포가 장렬하게 실패했습니다. 소스코드는 GIT으로 완벽하게 버전 관리가 되는데, DB 스키마(DDL)는 슬랙(Slack)으로 "DB 담당자님, 컬럼 하나 추가해 주세요~"라고 주먹구구식으로 소통하다 보니 싱크(Sync)가 안 맞는 것입니다.

 

"자바 코드처럼, DB 스키마(DDL) 변경 내역도 형상 관리(버전 컨트롤) 툴에 넣어서 서버가 뜰 때 자동으로 DB를 최신 상태로 맞춰줄 수는 없을까?"

다음 설명에서는 "DB 배포 사고 0%에 도전한다!" 데이터베이스 마이그레이션의 절대 표준, Flyway(플라이웨이) 아키텍처 완벽 가이드에 대해 아주 심도 있게 살펴보겠습니다.