Minecraft screenshot with moon setting in y=-59
개발
프론트엔드
기타 개발
Core Keeper 리모트 플레이 개발 - 클라이언트편
2024-07-24, 23:47 | HoonKun
Core Keeper 리모트 플레이 개발 - 클라이언트편

Compose(Jetpack, Multiplatform) 빠돌이의 좌충우돌 SwiftUI 도전기

서론

이 게시글 에서 좀 더 자세히 확인하실 수 있다. 여기서는 클라이언트의 개발에 대한 후기를 중점적으로 작성하므로, 서버가 궁금하다면 여기로 가보자!

대략, Windows 와 Linux 만 지원하는 Core Keeper 라는 게임을 휴대폰에서 플레이하고 싶어서 만든, 리모트 플레이어 앱의 클라이언트 개발 후기 이다.

구조

일반적인 iOS 어플리케이션이며, WebSocket 과 WebRTC 라이브러리를 사용하고 SwiftUI 로 표현된다.

프론트는 단순히 서버로부터 오는 미디어 데이터를 받아 표현하고, UI 로부터 들어오는 가상 컨트롤러 입력을 서버로 보내기만 하기 때문에 그다지 복잡한 구현이 없다.

그래서 이 후기의 내용도 거의 대부분이 SwiftUI 에 대한 불평이며, 왜 이걸 이따구로 만들어놨지 같은 내용이 95% 이상이다.

자, 그럼 시작해보자.

WebSocket

Starscream 의 웹소켓 라이브러리를 가져다 썼다.

얘는 이벤트 기반인데, 로직 설계 상 async 스타일이 더 선호되었다. 즉, connect 함수가 곧바로 리턴되고 대리자를 통해 .connected 이벤트에서 이어서 하는게 아니라, connect 함수가 async 함수이며 완전히 연결이 끝났을 때 리턴했으면 했다.

그래서 몇 가지 비동기 함수 유틸리티를 추가하여 async 스타일로 작성했다.

다만, 연결 해제 이벤트가 굉장히 모호했다. 내가 아는 웹소켓은 끊어졌거나(disconnected), 에러가 났거나(error) 둘 중 하나인데, 여기에는 peerClosedcanceled 라는 애가 또 있었다.

각각이 언제 발산되는지도 문서화되어있지 않아서... 스펙 문서를 봐야하나 고민했다. 지금은 네 이벤트 모두에 연결 해제 처리를 붙혀놨지만, 추후 명확한 발산 조건을 찾아서 추가 로직을 붙혀야할 것 같다.

WebRTC Framework

나름 잘 만들어놓은 것 처럼 보이지만, 내 요구사항은 충족할 수 없었다.

일단 미디어를 받는것에는 크게 문제가 없다. 영상도 잘 보이고 소리도 어느정도는 들린다.
문제는, 라이브러리 작성자가 미디어를 무조건 '주고 받는' 형태로 상정하고 구현했다는 점이다.

이번 프로젝트에서 클라이언트는 미디어를 받기만 하지, 보내지는 않는다. 그런데 이 프레임워크로 WebRTC 연결이 수립되면, 무조건 시스템의 마이크가 켜지며 소리를 녹음하려고 한다. 실제로는 아무것도 보내지 않음에도.

게다가 받는 오디오의 출력이 AVAudioSession 과 관련하여 녹음이랑 상호간섭을 하는지, 녹음을 못하게 AVAudioSession 카테고리를 변경해버리면 소리 출력이 제대로 안된다.
즉, AVAudioSession 의 카테고리와 관련하여 아래와 같은 문제가 있다:

  • .playAndRecord(기본값): 볼륨이 0으로 내려가지 않으며 마이크가 켜진다.
    • 얘는 같이 설정하는 AVAudioSession.Mode 에 따라 볼륨이 0으로 내려가기도 하나, 볼륨이 0인데도 스피커에서 소리가 난다.
  • .playback: 블루투스 이어폰을 사용할 수 없다.
  • .ambient, .soloAmbient: 아무 소리도 안난다.
  • .multiRoute: 볼륨 조절이 불가능하며 강제로 소리가 나고, 블루투스 이어폰도 못쓴다. 이런걸 도대체 왜 만들어놨지

일단 지금은 .playAndRecord 이기는 하다.
Info.plist 에서 권한 관련 메시지를 작성하고, 권한을 요청할 때 거절해버리면 마이크를 켜지 않는다.
또, 모드가 .default 이면 볼륨이 0으로 내려가지만 그럼에도 소리가 나는데, 이는 볼륨 변경 이벤트를 감시하고 0으로 떨어지면 받는 RTCAudioTrackisEnabled 프로퍼티를 false 로 설정해버렸다.

이렇게 하면 겨우 아래처럼 '일반적인 음악 플레이어와 비슷한 무언가'를 구현할 수 있다.

  • 기본적으로 스피커로 출력
  • 블루투스 이어폰이 있으면 거기로 출력
  • 볼륨을 0으로 줄일 수 있을 것
  • 마이크를 켜지 않을 것

진짜 왜 이렇게 만들어놨지. 그래서 이 후기를 다 적고 나면 WebRTC.framework 소스코드를 받아서 뜯어고쳐버릴 심산이다.

이 프레임워크가 오디오 출력에 관여하는 모든 코드를 삭제하고, WebRTC 를 통해 받는 오디오의 바이트 스트림을 직접 Swift 로 노출해서 직접 음악 플레이어처럼 출력할 것이다.

이게 다 프레임워크가 오디오 출력에 관여해서 생기는 문제다. 일반적으로 찾아보면 원래 위의 네 가지 조건을 충족하는게 어렵지 않으며 오히려 아무것도 안해도 저렇게 되는게 맞다고 한다.

Swift UI

대망의 SwiftUI에 대한 불평 시간이다.

실질 이번이 Swift UI 를 제대로 만진 첫 프로젝트인데, 기존에 KMM 으로 Compose Multiplatform 을 사용했으므로 그것과 비교해서 작성하려고 한다.

뷰의 구조

함수도 아니고 클래스도 아니고 구조체다. 내가 너무 함수형 컴포넌트에 쩔어있는것도 맞지만, 솔직히 눈에 안들어온다. 초기화 구문과 UI 로직이 분리되어있는게 오히려 걸리적거리고 로직 흐름이 눈에 들어오지 않는다.

함수라면 공평하게 위에서 아래로 흐를텐데, 얘는 ‘초기화는 한 번만 불리나? @State 초기화는 어떻게 이루어지지?… 여기다가 무거운 생성자 넣어도 되나?’ 같은 모호함이 많았다.

그에 반해 Compose 는 UI 요소가 함수이며, 구성 가능 함수의 '구성' 자체는 비동기적으로 동시에 실행될 수 있지만 어쨌든 위에서 아래로 흐른다.

프로퍼티 래퍼

당최 이게 무슨 짓을 하는지 알 수가 없다. @State 를 붙히면, stateName$stateName_stateName 이라는 세 개의 변수가 생기는데 이게… 이게 맞나.

게다가 이게 뭐는 값이고 뭐는 바인딩이고 뭐는... 뭔지도 모르겠다.

이와 다르게 Compose 는 상태도 함수로 만들어지며 by 를 사용한 Delegate 가 조금 난해한 편이긴 하지만 그걸 신경쓰지 않아도 될 정도로 잘 되어있다.

콘크리트 타입

이건 Swift 의 개념이지만, SwiftUI 와 결합하면서 매우 뭐같아졌다.

개인적으로 SwiftUI 에서 가장 골치아픈 존재라고 생각한다.

View 가 프로토콜이기 때문에, 그를 확장하는 뷰의 body getter 의 리턴타입이 some View 로 단 하나의 opaque 타입이어야 한다. 아니... 도대체 UI가 어떻게 하나의 타입을 가진단 말인가. UI 구성요소가 하나일 수가 없는데.

이것 때문인지는 몰라도 하나의 body 에 뷰를 많이 넣으면 컴파일러가 타입을 체크하다가 너무 힘들다면서 터진다. body 안의 모든 뷰를 체크하고 하나의 타입으로 만드려는 시도를 하는 것 같은데, 이게 중간에 컴파일 에러가 나면 골치아프다.

일반적으로 구조체의 초기화 인수가 빠지거나 이상한 걸 넣으면 발생하는데, 이러면 발생하는 일이 ‘이 인수가 빠졌어!’ 가 아니라 ‘이런 뷰 없어! (이런 이름과 초기화 구문을 가지는 뷰가 없어!)’ 이다. 즉 이 구문은 자기가 뭔지 모르는 구문이었고, 타입 체크가 실패하여 컴파일 에러가 잘못된 뷰에 표시되는게 아니라 var body: some View 나 이 뷰를 사용하는 부모 뷰에 출력된다.

너무 힘들어 징징징

너무 힘들어 징징징

즉, 실제 오류의 위치를 가려버리기 때문에 코드 일부분을 여기저기 주석처리해보면서 오류의 위치를 찾아다녀야 한다.

게다가 만약에 뷰가 children 이라도 가지면, 무조건 제너릭 타입 파라미터를 추가하고 some View 로 고정해줘야한다.
내 뷰가 정적 프로퍼티를 가지고 있었으면 여러모로 골치아프다.

반면에 Compose 에서는 함수 자체가 UI를 표현하기 때문에 아무것도 리턴하지 않으며, 따라서 타입도 없다. 모든 UI 는 그저 '@Composable 어노테이션으로 표시되는 구성 가능한 함수' 일 뿐이다.

수정자

SwiftUI 에서 두 번째로 골치아픈 존재.

사실 콘크리트 타입 때문에 더 골치아파진 존재인 것 같다.

구조체의 인스턴스에 갑자기 점을 찍고 수정자를 붙히는데, 이게 타입을 변경한다.

즉, TextforegroundStyle 을 붙히면 그대로 Text 가 나오는데, 그 뒤에 background 를 쓰면 이후의 타입은 더이상 Text 가 아니라 View 다. 그래서 background 를 먼저 쓰고 foregroundStyle 은 못쓴다. 수정자의 순서가 중요하다고 하더니 이런 의미인거냐고.

그리고 수정자가 View 의 확장 함수이기 때문에, 사실상 모든 뷰에 수정자를 붙힐 수 있다. 수정할 수 없게 설계된 뷰라고 해도 사용측에서 수정자를 쓰는 행위를 막을 수가 없다.

그리고 수정자의 동작도 모호하다. 아래의 코드를 보자:

struct SomeComponent: View {
	var body: some View {
		Text("ASDF")
		Text("ASDFASDF")
	}
}

SomeComponent().padding(4)

이런 것을 했다고 생각해보자. 과연 이 컴포넌트가 화면에 어떻게 보일까?

저 SomeComponent 를 작성한 개발자라면 그 내부를 알고 있으니 Text 두 개에 각각 위아래 4씩 붙어서 총 16의 패딩이 생길 것이라는 것을 알 수 있다. 근데 이걸 모르는 단순한 사용 측에서는 어떨까? 그냥 위와 아래에만 붙어서 8의 패딩이 생길 것이라고 생각할 것이다. 이게 진정 맞는가?

그래서 Compose 에서는 어떤 특정한 뷰에만 사용할 수 있는 수정 옵션은 모두 함수의 인수로 받는다. 예를 들어 Text 의 글씨 색을 수정하려고 한다면, 수정자를 붙히는게 아니라 Text 구성 가능 함수의 textStyle 인수(혹은 color 인수)에 값을 줌으로써 이루어진다.

즉, Compose 에서 수정자는 모든 UI 에 공통적으로 적용될 수 있는 것들이 대부분이다(일부 부모 스코프의 확장함수 형태로 제공되는 수정자들을 제외한다면).

게다가 이러한 수정자도 결국 함수의 인수로 전달되며, 그렇기에 함수에 Modifier 인수가 없으면 수정자를 붙힐 수 없고 함수가 받은 수정자를 내부의 어디에 사용할지도 명확하게 정할 수 있다(물론 이건 가장 구성 가능 함수의 가장 바깥쪽 UI에 쓰도록 권장하고 있지만).

더해서, 서로 다른 수정자 끼리 조합하거나, 뒤에 이어붙히거나 앞에 뭔가를 더 붙히는 등의 동작이 기본적으로 지원되는 등의 편리함이 많다.

뷰와 수정자의 예측할 수 없는 동작

요약하자면, 너무 쓸데없는 짓을 많이 한다.

레이아웃의 기본 정렬은 당연히 좌측 상단이어야 하는거 아닌가? 그렇지 않다. V, H, Z 스택 전부 중앙 정렬이 기본이다. 그래서 매번 alignment 를 붙혀주고 다니는데, 정신나갈 것 같다. 진짜.

VStack(이나 HStack) 의 기본 spacing(nil)은, 서로 다른 애들끼리는 간격을 붙히지 않으면서 서로 같은 애들은 시키지도 않은 간격을 붙힌다. 이 동작을 끄려면 매번 spacing: 0 을 붙혀줘야한다.

ZStack 은 자기 자신의 크기가 아닌 자식들의 크기 총합을 기준으로 정렬한다. 이 말은 ZStack 안에 단 하나의 뷰만 있으면 아무리 스택에 alignment: .leading 을 붙혀도 의미가 없다는 말이 된다. frame 수정자를 통해 너비/높이를 키우고 여기에 alignment 을 줘야 한다. 혹은 ZStack 안에 Spacer 를 넣어 자식의 크기 총합을 강제로 늘리거나…

버튼을 만들었는데, 거기에 opacity 를 붙히면 갑자기 버튼이 안눌린다. contentShape 라는 수정자가 추가로 필요하단다.

어떤 ZStack 의 자식에 transition 을 붙히면, 나머지 자식들의 행동이 이상해진다. 이건 뷰가 사라지고 생성됨에 따라 zIndex 가 유동적으로 변경되는데, 그에 따른 문제라서 zIndex 수정자로 명시적으로 정해주면 해결된다고 한다. 근데 어차피 코드 내에서 UI의 위치는 변하지 않는데 그게 왜 문제지.

게다가 이 transition 자체의 동작도 어딘가 나사가 빠졌다. 예를 들어 아래와 같은 코드가 있다고 생각해보자.

VStack {
    if visible {
        ElementA().transition(.opacity.combined(.offset(y: -60)))
    }
    if visible {
        ElementB().transition(.opacity)
    } else {
        ElementC().transition(.opacity)
    }
}

VStack 의 가장 위에 있는 A의 존재 여부가 변경되며, 그에 맞춰 그 아래의 B 와 C 가 전환된다.

이런 상황에서 나는 아래 처럼 전환되기를 기대한다:

expected

기대된

그러나 실제로는 아래처럼 전환된다:

actual

실제

뭐가 문제일까? 바로 뷰의 scale 이 문제다. 만약 위의 코드에서 ElementBElementC.scaleEffect(1.0) 수정자를 붙히면 바로 기대한대로 동작한다.

응? 크기에 1.0을 넣었으면 안변한거 아니냐고? 그렇지 않다. Swift 의 세상에서 이건 변한거다. 아무튼 그런거다.

XCode

XCode

이야 그래도 이젠 1.9나 되네 옛날에 봤을 때는 1.4인가 그랬는데

더이상의 자세한 설명은 생략한다.

후기

여기까지 내려오셨다면 아시겠지만 '진짜 왜 이따위로 만들었지'가 대부분이었다.

그렇기에, 뭔가가 안되어서 수정해놓고도 '이게 왜 되지'인 경우가 많았다. 직관적으로 만들지도 못해놓고 동작을 내부에 꽁꽁 숨겨버렸으니 당연한 얘기다.

만약 KMM 으로 WebRTC 의 RTCMTLVideoView 를 이식할 수 있었다면 절대 Swift 로 작성하지 않았을 것이다.

어?... 잠깐. 이거 진짜 안되는건가?

키위새의 아무말 저장소
  • 개발
  • 마인크래프트
  • 생명과학II
  • 아무말
Blog Logo