
1편은 Weylus 로 폰 미러링, 2편은 evdi 로 가상 확장 모니터를 만들었습니다. 이번에는 전송 쪽을 바꿔 봤습니다. 게임 스트리밍용으로 만들어져 지연이 짧고 GPU 인코딩을 쓰는 Sunshine(노트북) + Moonlight(폰) 입니다. Pop!_OS COSMIC 에서는 설치 직후 전혀 동작하지 않았고, 벽 다섯 개를 넘어야 했습니다.
벽 0: 패키지 설치부터 Qt 버전 충돌
Ubuntu 24.04 용 .deb 는 Qt 6.4.2 를 요구하는데, 이 시스템에는 Qt 6.8.3 이 깔려 있어 "망가진 고정 패키지"로 설치가 거부됐습니다. Qt 를 다운그레이드하면 다른 앱이 깨질 수 있어서, 라이브러리를 모두 품은 AppImage 를 풀어서 쓰기로 했습니다. 시스템 파일은 건드리지 않습니다.
벽 1: 포털이 화면 없는 세션을 돌려준다
Wayland 에서는 화면을 포털(xdg-desktop-portal)로 받아야 합니다. Sunshine 은 원격 제어(RemoteDesktop)와 화면 공유(ScreenCast)를 한 세션으로 요청하는데, COSMIC 포털은 이 조합을 "성공"으로 돌려주면서 화면 스트림을 넣지 않았습니다(No streams available). 실패가 아니라서 Sunshine 의 "화면 공유만" 폴백도 동작하지 않았습니다.
해결은 사용자 설정 한 파일입니다. 원격 제어 포털만 끄면 Sunshine 이 화면 공유 단독 모드로 넘어가고, 입력은 원래대로 /dev/uinput 으로 전달됩니다. 끄기 전에 Weylus 소스를 확인해 보니 원격 제어 포털은 GNOME 에서만 써서 영향이 없었습니다.
# ~/.config/xdg-desktop-portal/cosmic-portals.conf
[preferred]
default=cosmic;gtk;
org.freedesktop.impl.portal.Secret=oo7-portal;gnome-keyring;
org.freedesktop.impl.portal.RemoteDesktop=none벽 2: 60fps 와 0fps 는 만나지 않는다
화면을 받자 PipeWire 협상이 no more output formats 로 실패했습니다. 포맷(BGRA)은 겹쳤는데 프레임레이트가 문제였습니다. 두 쪽이 내놓는 조건을 직접 덤프해 비교했습니다.
| 포맷 | 프레임레이트 | |
|---|---|---|
| COSMIC (보냄) | BGRA·RGBA | 60/1 고정 |
| Sunshine (받음) | BGRA·BGRx 등 | 0/1 고정(가변 표시) |
Sunshine 에는 "maxFramerate 없이 재시도" 경로가 있지만 스트리밍 도중에만 동작하고, 시작 때 인코더를 시험하는 단계에는 없었습니다. 소스를 고쳐 빌드하는 대신, LD_PRELOAD 끼움 라이브러리로 PipeWire 연결 함수(pw_stream_connect)에 끼어들어 요청 속 0/1 을 60/1 로 바꿨습니다. 바이너리 직접 패치도 검토했지만, 같은 상수를 다른 함수 수십 곳이 공유하고 있어 포기했습니다.
벽 3: 가끔 영원히 멈추는 서버(dell offline)
협상이 풀리자 이번에는 Sunshine 이 시작 도중 매번 다른 시점에 멈췄고, 폰에는 "offline" 이 떴습니다. debug 로그를 보니 포털 호출 하나가 응답을 영영 받지 못하고 있었습니다. 원인은 고전적인 경쟁 상태였습니다.
- Sunshine 은 포털 메서드를 호출하고, 호출이 돌아온 뒤에야 결과 신호(Response)를 구독한다.
- COSMIC 포털은 응답이 매우 빨라서, 가끔 그 사이에 신호가 먼저 도착해 버린다.
- 이미 지나간 신호를 기다리며 서버 전체가 멈춘다. 인코더 시험 때 세션을 수십 번 여닫으니 결국 한 번은 걸린다.
같은 끼움 라이브러리에 두 함수를 더 넣었습니다. 첫 포털 호출 전에 모든 Response 신호를 받아 두는 구독을 먼저 걸고, Sunshine 이 늦게 구독하면 받아 둔 신호를 대신 전달합니다.
벽 4: GPU 인코더가 CPU 인코더로 바뀌어 있다
드디어 시작은 됐는데 인코더가 libx264(CPU)로 잡혔습니다. 끼움 라이브러리를 붙이려고 AppImage 실행기를 거치지 않고 바이너리를 직접 실행하면서 작업 폴더가 바뀌었고, Sunshine 이 셰이더를 상대 경로(./usr/share/...)로 읽다 실패한 것이었습니다. AppDir 로 이동해서 실행하니 GPU 인코더(h264_vaapi)가 잡혔습니다.
덤으로 겪은 LD_PRELOAD 함정도 있습니다. 실행 스크립트에서 LD_PRELOAD 를 전역으로 export 했더니 sleep·tail 같은 일반 명령이 undefined symbol: g_free 로 죽었습니다. glib 함수를 포인터 값으로 쓰면 로드 즉시 심볼을 찾기 때문입니다. 래퍼 함수로 감싸 lazy binding 이 되게 하고, LD_PRELOAD 는 Sunshine 실행 한 줄에만 붙였습니다.
벽 5: 페어링 — 오프라인과 사라지는 PIN 창
- 재시작할 때마다 오프라인: Sunshine 은 서버 고유 ID 를 페어링할 때 처음 파일에 저장합니다. 페어링 전에 재시작하면 ID 가 새로 만들어지고, Moonlight 는 예전 ID 를 기억하고 있어 응답을 받으면서도 오프라인으로 표시합니다. PC 를 지우고 다시 추가하면 됩니다.
- PIN 창이 떴다 바로 사라짐: Moonlight 는 모든 요청에 같은 클라이언트 ID 를 씁니다. 첫 시도에서 만든 페어링 세션이 5분간 남아 있어, 그 뒤 시도는 전부 409(이미 페어링 중)로 즉시 거절됩니다. 웹 설정의 PIN 화면을 먼저 열어 두고, 폰에서는 한 번만 누르는 게 요령입니다.
결과
폰에서 노트북 화면이 GPU 인코딩(h264_vaapi)으로 나옵니다. 스트리밍 중 Sunshine 의 CPU 사용률은 7.2% 로, 같은 노트북에서 Weylus 를 쓸 때 측정한 약 30% 보다 가벼웠습니다(각각 측정 시점 기준). 소리를 노트북 스피커로 유지하려면 Sunshine 설정에 stream_audio = disabled 를 넣으면 됩니다.
다음 편: 이 Sunshine 에 2편의 evdi 가상 모니터를 붙여 진짜 확장까지 가 본 이야기와, 거기서 나온 숫자 하나입니다.