블로그로 돌아가기
Transport16 min read

DLNA 캐스트: UPnP 송신기와 수신기를 처음부터 구축하기

PlainApp이 DLNA/UPnP AV 캐스팅을 양방향으로 구현하는 방법: 송신기로서 SOAP을 통해 TV를 스캔하고 제어하며, 수신기로서 스마트폰 자체를 UPnP MediaRenderer로 전환 — SSDP 디스커버리, DIDL-Lite 메타데이터, Range-request 미디어 서빙, GENA 이벤트 콜백, 그리고 송신자 IP 허용/차단 신뢰 모델까지, 모두 순수 Kotlin Multiplatform으로 구현.

DLNA (UPnP AV 기반)는 스마트 TV에서 "TV로 캐스트" 버튼 뒤에 있는 프로토콜입니다. 이 프로토콜은 전적으로 로컬 네트워크에서 작동하며, 클라우드 계정이나 페어링 단계가 필요 없습니다. PlainApp은 이 프로토콜의 양방향을 모두 구현합니다: 로컬 비디오, 음악 또는 사진을 모든 DLNA 호환 스마트 TV로 푸시할 수 있고, 스마트폰 자체를 MediaRenderer로 전환하여 TV 리모컨 앱, VLC 또는 다른 PlainApp이 이쪽으로 캐스트할 수 있게 합니다.

이 글은 와이어 프로토콜(SSDP + SOAP + DIDL-Lite), TV가 실제로 요구하는 헤더와 함께 미디어를 스트리밍하는 로컬 HTTP 서버, 재생 상태를 위한 GENA 이벤트 구독, 그리고 인증이 없는 1990년대 프로토콜을 현대 스마트폰에서 안전하게 실행할 수 있게 해주는 송신자 IP 신뢰 모델을 다룹니다.

목차

상위 수준 아키텍처

Diagram 1
1

DLNA/UPnP AV에는 중앙 서버나 클라우드 구성 요소가 없습니다 — 모든 것은 로컬 네트워크 UDP 멀티캐스트(디스커버리)와 HTTP(제어 + 미디어)를 통해 이루어집니다. PlainApp은 동일한 commonMain 프로토콜 코드를 공유하는 두 개의 독립적인 역할을 구현합니다:

역할기능주요 클래스
송신기 ("TV로 캐스트")렌더러를 스캔하고, URL을 가져오도록 지시하며, 재생을 제어합니다DlnaDeviceScanner, DlnaTransportController, CastPlayer
수신기 ("무선 캐스트")자신을 MediaRenderer로 광고하고, 모든 UPnP 컨트롤러의 제어를 수락합니다DlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

두 역할 모두 features/dlna/common/의 DlnaSoap(SOAP 봉투 빌더)과 DlnaDevice(UPnP 기기 모델)를 재사용합니다. DLNA 사양에는 어떤 종류의 인증도 없습니다 — LAN 상에서 렌더러의 제어 URL을 아는 사람이라면 누구든 명령을 보낼 수 있습니다. 이는 아래 설명되는 거의 모든 설계 결정, 특히 수신기의 신뢰 모델을 형성합니다.

SSDP 디스커버리: 서버 없이 기기 찾기

디스커버리는 SSDP(Simple Service Discovery Protocol)를 사용하며, 이는 UDP 멀티캐스트 239.255.255.250:1900 위의 얇은 계층입니다. 디렉토리 서버가 없습니다 — 기기들이 스스로를 알리고 검색 쿼리에 직접 응답합니다.

Diagram 2
2

송신기로서, DlnaDeviceScanner는 urn:schemas-upnp-org:service:AVTransport:1을 대상으로 하는 M-SEARCH 데이터그램을 브로드캐스트하고, 각각 렌더러의 description.xml을 가리키는 LOCATION 헤더를 포함한 유니캐스트 200 OK 응답을 수집합니다. 스캐너는 hostAddress로 중복을 제거합니다 — 기기 XML 자체를 파싱하지는 않으며, 이 작업은 CastViewModel.searchAsync()에 위임되어 LOCATION을 가져오고, device.update(xml)을 호출하며, device.isAVTransport()가 true를 반환하는 기기만 표시합니다:

fun search(): Flow<DlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->
    if (devices.none { it.hostAddress == ssdp.hostAddress }) {
        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)
        devices.add(device)
        emit(device)
    }
}

수신기로서, DlnaReceiverEngine.runSsdpLoop()는 시작 시 세 개의 NOTIFY ssdp:alive 데이터그램(루트 기기, MediaRenderer:1 기기 유형, AVTransport:1 서비스 유형)을 보내고, 30초마다 재알림하며(CACHE-CONTROL: max-age=1800), 들어오는 M-SEARCH 요청에 유니캐스트 응답으로 답합니다. stop()이 호출되면 30분 캐시가 만료될 때까지 기다리지 않고 즉시 ssdp:byebye를 보냅니다 — 따라서 사용자가 "무선 캐스트"를 끄는 순간 TV 리모컨 앱이 PlainApp을 더 이상 나열하지 않게 됩니다.

포트 폴백

수신기의 HTTP 서버는 먼저 포트 7878을 시도한 다음 7879, 7880을 시도하며, 마지막으로 작동했던 포트를 선호합니다:

private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)
private fun openServerSocket(): DlnaServerSocket? {
    val candidates = lastPort
        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }
        ?: CANDIDATE_PORTS
    for (port in candidates) {
        val ss = createDlnaServerSocket(port)
        if (ss != null) return ss
    }
    return null
}

세 포트가 모두 사용 중인 경우(드물지만 다른 DLNA 앱이 실행 중일 때 가능), startError가 설정되고 UI에 표시되어 조용히 실패하지 않습니다.

AVTransport 제어: SOAP 프로토콜

렌더러가 발견되면, 재생은 UPnP AVTransport를 통해 제어됩니다 — SOAP-over-HTTP 서비스입니다. 모든 액션은 SOAPAction 헤더와 SOAP 봉투에 감싸인 XML 본문과 함께 렌더러의 제어 URL로 POST하는 것입니다.

Diagram 3
3

DlnaTransportController는 공유 헬퍼로 각 요청을 구성합니다:

private suspend fun executeAVTransportCommand(
    device: DlnaDevice,
    action: String,
    parameters: String = "<InstanceID>0</InstanceID>",
): String {
    val st = device.getAVTransportService()?.serviceType ?: return ""
    return executeSOAPRequest(device, action, "<u:$action xmlns:u=\"$st\">$parameters</u:$action>")
}

executeSOAPRequest는 SOAPAction: "<serviceType>#<action>"을 설정하고 DlnaSoap.requestEnvelope(soapBody)를 POST합니다 — 수신기 측에서 응답을 구성하는 데 사용하는 것과 동일한 봉투 상수이므로, 와이어 형식은 commonMain에서 한 번만 정의하면 됩니다.

수신기의 DlnaHttpRouter.handleSoap()는 반대쪽에서 이를 미러링합니다: soapaction 헤더를 읽고, # 뒤의 액션 이름을 추출하여 디스패치합니다 — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl(볼륨)은 고정값 100으로 스텁 처리됩니다 — PlainApp은 UPnP를 통해 기기 볼륨을 노출하지 않습니다.

DIDL-Lite 메타데이터와 이중 이스케이핑 특성

SetAVTransportURI는 두 개의 매개변수를 전달합니다: CurrentURI(미디어 URL)와 CurrentURIMetaData — 제목, 미디어 클래스, 앨범 아트를 설명하는 DIDL-Lite XML 조각으로, 외부 SOAP 본문 내부에 XML 이스케이프된 문자열로 포함됩니다:

private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {
    val upnpClass = when {
        ext in setOf("mp3", "m4a", "flac", ...) -> "object.item.audioItem.musicTrack"
        ext in setOf("jpg", "jpeg", "png", ...) -> "object.item.imageItem"
        else -> "object.item.videoItem"
    }
    val didl = """<DIDL-Lite xmlns="..."><item id="0" parentID="-1" restricted="0">
        <dc:title>$escapedTitle</dc:title><upnp:class>$upnpClass</upnp:class>$albumArtTag</item></DIDL-Lite>"""
    return didl.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

DIDL-Lite XML은 두 번 이스케이프됩니다: 한 번은 제목 텍스트 자체를 위해(Fire & Ice라는 노래가 DIDL-Lite 태그를 망가뜨리지 않도록), 또 한 번은 전체 DIDL-Lite 문서를 위해(자체 </>가 텍스트로 포함된 외부 SOAP 봉투를 망가뜨리지 않도록). 이는 잘 알려진 UPnP 특성이며 버그가 아닙니다 — CurrentURIMetaData는 중첩된 XML 요소가 아닌 문자열 콘텐츠로 정의됩니다.

수신기 측에서 DlnaSoapHandler는 이를 역으로 수행합니다: parseSoapAction이 SOAP 본문을 한 번 언이스케이프하여 DIDL-Lite 텍스트를 얻은 다음, extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/extractAlbumArtUriFromDidlMeta가 각각 내부 문자열에 대해 두 번째 엔티티 언이스케이프 및 태그 추출을 수행합니다:

fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = ""): DlnaMediaType {
    val cls = meta.substring(classStart + 12, classEnd).lowercase()
    return when {
        "audioitem" in cls || "musictrack" in cls -> DlnaMediaType.AUDIO
        "imageitem" in cls || "photo" in cls -> DlnaMediaType.IMAGE
        "videoitem" in cls -> DlnaMediaType.VIDEO
        else -> DlnaMediaType.UNKNOWN
    }
}

<upnp:class>가 누락된 경우(일부 송신기가 생략함), cleanMediaTitle()은 URI 자체의 파일 확장자로 폴백합니다 — 미디어 유형 감지는 절대 하드 실패하지 않으며, 최악의 경우 UNKNOWN으로 저하되어 안전한 기본값인 비디오 플레이어로 라우팅됩니다.

TV에 미디어 제공: Range 요청 및 DLNA 헤더

SetAVTransportURI 호출은 렌더러에게 미디어를 어디서 가져올지만 알려줍니다 — 실제 바이트는 PlainApp 자체 로컬 HTTP 서버가 /media/{id}에서 제공합니다.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path)는 실제 경로(content:// URI, 원격 URL 또는 일반 파일 경로일 수 있음)를 짧은 id로 등록하고 http://<device-ip>:<port>/media/<id>.<ext>를 반환합니다. 그런 다음 라우트는 실제 소스 종류에 따라 분기합니다:

when {
    path.isUrl() -> call.proxyUrl(path)                 // 원격 URL: 업스트림 응답 스트리밍
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // 이미지: 일반 정적 서빙
    else -> call.respondDlnaFile(path)                   // 오디오/비디오: DLNA 인식 서빙
}

respondDlnaFile이 흥미로운 경우입니다 — 많은 스마트 TV와 DLNA 렌더러는 응답이 적절한 DLNA 미디어 서버 응답처럼 보이지 않으면 스트림 재생을 거부합니다:

override suspend fun respondDlnaFile(path: String): Boolean {
    val file = java.io.File(path)
    if (!file.exists()) return false
    applicationCall.response.run {
        header("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
        header("contentFeatures.dlna.org", "")
        header("transferMode.dlna.org", "Streaming")
        header("Connection", "keep-alive")
        header("Server", "DLNADOC/1.50 UPnP/1.0 Plain/1.0 Android/${android.os.Build.VERSION.RELEASE}")
        status(HttpStatusCode.PartialContent) // 일부 TV OS는 206만 수락
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

상태 코드는 항상 206 Partial Content이며 200 OK가 아닙니다 — 일부 TV 펌웨어는 일반 200 응답을 "탐색 불가능"으로 간주하고 재생을 거부합니다, 심지어 전체 파일 GET 요청에서도 마찬가지입니다. 이 하나의 상태 코드 선택이 여러 실제 기기에서 "잘 재생됨"과 "TV가 영원히 스피너를 표시함"의 차이를 만듭니다.

동일한 라우트는 캐스트 오디오 항목의 앨범 아트도 제공합니다: UrlHelper.getAlbumArtHttpUrl()은 content://media/.../albumart/<id> URI를 동일한 /media/{id} 경로에 매핑하므로, 해당 "미디어"가 노래인지 커버 이미지인지에 관계없이 content:// 스트리밍과 DLNA 파일 서빙이 하나의 코드 경로를 공유합니다.

GENA 이벤트: 재생 상태 콜백

재생을 시작한 후, 송신기는 렌더러의 AVTransport 이벤트 서비스(GENA — General Event Notification Architecture)를 구독하여 폴링 없이 상태 변경을 알 수 있습니다:

suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {
    val service = device.getAVTransportService() ?: return ""
    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {
        headers { set("NT", "upnp:event"); set("TIMEOUT", "Second-3600"); set("CALLBACK", "<$callbackUrl>") }
    }
    return response.headers["SID"].orEmpty()
}

SUBSCRIBE/RENEW/UNSUBSCRIBE는 사용자 정의 HTTP 메서드(표준 동사 집합에 없음)로, Ktor의 일반 HttpMethod("SUBSCRIBE") 요청 빌더를 통해 처리됩니다. 그러면 렌더러는 전송 상태, 위치 또는 길이가 변경될 때마다 callbackUrl — PlainApp 자체의 /callback/cast 라우트 — 에 NOTIFY를 보냅니다:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // 다음 재생목록 항목으로 이동
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

중복 콜백 방어

일부 렌더러는 동일한 STOPPED 전환에 대해 두 번의 NOTIFY 콜백을 연속으로 보냅니다 — 두 번째 것은 AVTransportURIMetaData를 포함하는 반면 첫 번째 것은 그렇지 않습니다. 둘 다에서 재생목록을 진행하면 재생이 자연스럽게 멈출 때마다 트랙을 하나 건너뛰게 됩니다. !xml.contains("AVTransportURIMetaData") 검사는 의도적이고 좁은 필터입니다: 첫 번째 (메타데이터 없는) STOPPED 알림만 자동 진행을 트리거합니다. startPositionUpdater() 작업은 또한 매초 GetPositionInfo를 폴링하여 폴백으로 사용합니다. PlainApp 자체의 SUBSCRIBE 확인은 다른 컨트롤러로 이벤트를 푸시하지 않기 때문입니다 — 송신기 측만 TV의 GENA 콜백을 소비합니다.

수신기 모드: UPnP MediaRenderer 되기

방향을 뒤집습니다: 모든 DLNA 컨트롤러(TV 리모컨 앱, VLC, 다른 PlainApp)가 스마트폰 자체로 미디어를 푸시할 수 있습니다. DlnaReceiverEngine은 위에서 설명한 것과 동일한 종류의 HTTP + SSDP 서버를 열지만, 이번에는 컨트롤러가 아닌 제어되는 MediaRenderer로서입니다.

DlnaHttpRouter.route()는 description.xml(DlnaXmlTemplates.deviceDescription()에 의해 구축되며, AVTransport 서비스와 스텁 RenderingControl 서비스를 나열함)을 제공하고 SOAP 액션을 DlnaSoapHandler에 디스패치합니다. SetAVTransportURI 호출은 즉시 아무것도 재생하지 않습니다 — PendingCastRequest를 저장하고 기다립니다:

if (uri.isNotEmpty()) {
    DlnaRendererState.rawPendingCastRequest.value =
        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
    DlnaRendererState.pendingPlayQueued.value = false
}

보류 중인 요청이 해결되기 전에 도착한 Play도 재생을 시작하지 않습니다 — pendingPlayQueued = true를 설정하여 캐스트 요청이 수락되면 명령이 자동으로 재생되도록 하여 조용히 손실되지 않도록 합니다:

val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
    DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
    DlnaRendererState.pendingPlayQueued.value = true
} else {
    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}

수락된 명령은 DlnaReceiverViewModel이 소비하는 단일 Channel<DlnaCommand>를 통해 흐르며, 원시 소켓 처리 코루틴을 UI/플레이어 상태에서 분리합니다 — HTTP 핸들러는 ExoPlayer를 직접 건드리지 않습니다. 그런 다음 재생은 DlnaMediaType에 따라 세 가지 전체 화면 Composable 중 하나로 라우팅됩니다: DlnaReceiverAudioPlayerContent(그라데이션 배경, 앨범 아트, 탐색 바), 이미지 뷰어, 또는 DlnaReceiverVideoPlayerContent(ExoPlayer).

보안: 허용/차단 목록을 통한 송신자 신뢰

DLNA는 설계상 인증이 없습니다 — LAN 상의 모든 기기가 렌더러에 SetAVTransportURI를 보낼 수 있습니다. 개인 스마트폰을 인증되지 않은 MediaRenderer로 전환하면 동일한 Wi-Fi(공유 사무실 네트워크, 친구 집, 적대적인 게스트 네트워크)에 있는 누구나 임의의 미디어 URL을 푸시할 수 있습니다. PlainApp은 송신자 IP별 신뢰 목록으로 이 격차를 해소하며, 들어오는 모든 캐스트 요청을 검사합니다:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // 자동 수락: 대화상자 없이 직접 명령 전송
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // 자동 거부: 조용히 폐기
        }
        else -> {
            // 알 수 없는 송신자: UI 표시 상태로 승격하여 사용자가 결정
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

알 수 없는 송신자의 캐스트 요청은 선택적 "이 선택 기억하기" 플래그가 있는 확인 대화상자를 표시합니다; 기억하기를 선택하면 송신자의 IP를 허용 또는 차단 설정에 기록하여 이후 동일한 주소의 요청이 대화상자를 건너뜁니다. 이는 와이어 프로토콜 위의 DlnaReceiverViewModel에서 전적으로 적용됩니다 — SOAP 핸들러 자체는 신뢰 결정과 관계없이 항상 200 OK를 반환합니다(UPnP 사양에 따라, 전송 호출은 성공했으며 미디어가 실제로 재생되는지는 별도의 로컬 결정입니다).

플랫폼 분할: commonMain 오케스트레이션, androidMain 소켓

Diagram 6
6

PlainApp의 다른 네트워크 기능과 동일한 패턴을 따라, 원시 바이트 수준의 소켓 I/O만 플랫폼별로 구현됩니다. DlnaServerSocket과 DlnaSsdpSocket은 expect 인터페이스입니다; Android의 actual 구현은 java.net.ServerSocket과 java.net.MulticastSocket을 직접 래핑합니다. 그 외 모든 것 — 포트 선택, SSDP alive/byebye 주기, HTTP 라우팅, SOAP 파싱, DIDL-Lite 메타데이터, 그리고 네 가지 DlnaCommand 상태 전환 모두 — 는 commonMain에 있으며 Android 기기 없이 JVM에서 단위 테스트가 가능합니다.

iOS는 송신기 측 SOAP 제어(일반 HTTP 클라이언트이므로 구현할 소켓이 없음)를 얻지만, 수신기는 의도적으로 no-op입니다:

actual fun startDlnaRenderer() {}

iOS는 백그라운드 UDP 멀티캐스트 리스너를 충분히 안정적으로 실행할 방법을 제공하지 않아 좋은 MediaRenderer가 될 수 없으므로, "무선 캐스트"(수신)는 Android 전용입니다; "TV로 캐스트"(송신)는 두 플랫폼 모두에서 작동합니다.

순수 Kotlin 엔지니어링 노트

Kotlin Multiplatform 공유를 깨뜨릴 플랫폼 의존성을 피하기 위해 존재하는 몇 가지 구현 세부 사항이 있습니다:

  • UUID v4 생성. DlnaReceiverEngine.randomUuid()는 java.util.UUID.randomUUID() 대신 Random.nextBytes(16)으로 RFC 4122 v4 UUID를 수동 구현하여, 기기 식별 생성기가 모든 플랫폼에서 동일하도록 합니다.
  • 퍼센트 디코딩. DlnaSoapHandler의 비공개 percentDecode()는 java.net.URLDecoder.decode()를 대체하여 미디어 URI에 URL 인코딩된 형태로 도착하는 제목을 처리합니다.
  • 바이트 수준 HTTP 본문 읽기. Content-Length는 바이트 카운트이지 문자 카운트가 아닙니다. AndroidDlnaClientConnection.readHttpRequest()는 readBodyBytes(bis, contentLength)를 통해 원시 BufferedInputStream에 대해 본문을 읽으며, 절대 BufferedReader/CharArray를 사용하지 않습니다 — 멀티바이트 UTF-8(예: 한글 문자, 각 3바이트)을 포함하는 제목은 그렇지 않으면 본문을 덜 읽어 이미 도착한 바이트를 기다리며 멈춰 서서, 비-ASCII 제목에 대해 SetAVTransportURI를 조용히 깨뜨릴 것입니다.
  • java.net.URL 없이 Base URL 파싱. DlnaDevice.getBaseUrl()은 java.net.URL을 생성하는 대신 일반 문자열 슬라이싱(substringAfter("://"), substringBefore('/'))으로 LOCATION 헤더에서 스킴/호스트/포트를 추출합니다.

디자인 패턴 요약

패턴위치이유
프로토콜 공유, I/O 분할DlnaSoap/DlnaXmlTemplates는 commonMain, 소켓은 androidMain와이어 형식을 한 번 정의하고, 플랫폼은 바이트 입출력만 제공
보류 → 규칙 검사 → 승격rawPendingCastRequest → pendingCastRequest"요청이 도착함"과 "요청에 사람의 결정이 필요함"을 분리
명령 큐 분리Channel<DlnaCommand>HTTP 핸들러 코루틴이 ExoPlayer/UI 상태를 직접 건드리지 않음
대기 명령 재생pendingPlayQueuedSetAVTransportURI의 승인보다 먼저 도착한 Play가 버려지지 않음
의도적 상태 코드 선택respondDlnaFile → 항상 206사양 최소 요구사항인 200이 아닌, 실제 TV 펌웨어가 기대하는 것과 일치
좁은 중복 필터!xml.contains("AVTransportURIMetaData")일반적인 중복 제거 메커니즘 없이, 일부 렌더러가 보내는 두 STOPPED 콜백을 구분
즉시 byebyestop()이 취소 전에 ssdp:byebye 전송사용자가 기능을 끈 후 30분짜리 오래된 SSDP 캐시 항목이 남지 않도록 방지
알 수 없음은 열림, 기본값은 닫힘허용/차단 설정 + 대화상자처음 보는 송신자를 자동 신뢰하지도 자동 차단하지도 않음 — 사람이 한 번 결정

추가 자료

  • UPnP Device Architecture — 기반이 되는 SSDP/GENA/SOAP 사양.
  • WebSocket 기반 저지연 화면 미러링 기능(DLNA와 다른 캐스팅 방식)에 대해서는 Screen Mirror를 참조하세요.