시작하며
안녕하세요. 카카오엔터프라이즈 워크Web개발파트에서 프론트엔드 개발을 하고 있는 Dale(조한별)입니다.
이번 포스팅에서는 지난 8월 카카오워크 신규 기능으로 탑재된 음성채팅을 구현하면서 얻은 프론트엔드 개발 경험을 소개해볼까 합니다. 카카오워크 PC 버전(Mac, Windows)에 구현되어 있는 음성채팅은 JavaScript로 구현된 Webview(이하 웹뷰)로 개발되었습니다. 아무래도 웹뷰로 구현하게 되면 빠른 구현과 간편한 배포를 통해 유지보수가 편하지만, 웹뷰라는 틀 안에서 음성 기술을 다루다 보니 각 플랫폼 별로 제약사항이 꽤 존재했던 만큼 고려해야되는 한계점들도 분명히 존재했습니다. 이 포스팅이 WebRTC 기술을 활용하여 웹뷰로 비슷한 서비스를 개발하시는 분들에게 소소한 팁이 될 수 있었으면 좋겠습니다.
카카오워크 음성채팅
카카오워크의 음성채팅 기능은 메시지 형식의 커뮤니케이션 방식에서 한발 더 나아가 음성으로 서로 연결된 느낌의 커뮤니케이션을 제공합니다. 음성채팅은 기존 화상회의보다는 간단하지만, 텍스트보다는 빠르게 의사소통할 수 있는 서비스를 제공한다는 점에 주목하며 개발하게 되었는데요. 카카오워크 음성채팅은 로컬 오디오와 스피커의 상태/종류를 변경하거나 참여자들의 음량 레벨을 통해 발화자를 표시하고, 메인 스피커(주발화자)를 판단하여 표시할 수 있습니다. 뿐만 아니라 참여자들의 정보와 미디어 기기 권한 여부를 표시하는 등 음성채팅을 위한 기본적인 기능들을 간단하고 빠르게 제공합니다.
❕카카오워크 음성채팅 사용 방법
카카오워크 PC 버전에서 음성채팅 사용법을 궁금해하시는 분들을 위해 사용법을 설명드리면, 사용법은 아주 간단합니다. 채팅방 우상단에 있는 🎧아이콘을 누르면 바로 채팅방 멤버들과 음성채팅이 시작됩니다.
음성채팅이 시작되면 [그림 2]와 같이 초록색 아이콘이 채팅창 목록과 채팅방 우상단에 표시됩니다. 저 같은 경우 개발하면서 동료들과 그때 그때 잠깐씩 빠르게 이야기를 나눌 때(그리고 화상회의는 뭔가 부담스러울 때), 음성채팅을 정말 유용하게 사용하고 있는데요. 개발 구조에 대해 텍스트로 의견을 나누는 것보다, 클릭 한 번으로 음성채팅을 통해 안건을 빠르게 논의할 수 있어 참 편리합니다. 🙂
음성채팅 구현에 사용한 기술
카카오워크의 음성 채팅을 구현하기 위해 제가 사용했던 기술들을 공유해 보겠습니다.
음성 라이브스트리밍 with WebRTC
음성 채팅에서 가장 핵심적인 기능은 역시나 음성 라이브스트리밍 기술입니다. 음성 라이브스트리밍 기술은 카카오엔터프라이즈의 자체 WebRTC 기술을 사용했습니다. 음성채팅은 화상이 아닌 음성만을 다루다 보니 안정적인 음성 전달과 음성 품질이 매우 중요합니다. 음성채팅을 개발하면서 사내에서 음성 성능 테스트를 진행한 적이 있었는데요. 몇백 명 수준의 인원이 접속해도 성능이나 품질 면에서 이상이 없는 부분을 확인했고, 덕분에 성능에 대한 의심 없이 다른 기능 구현에 집중할 수 있었습니다.
웹뷰 with Svelte
음성채팅의 웹뷰는 Svelte(스벨트)를 사용하여 개발하였습니다. Svelte는 제가 최근에 관심을 갖고 열심히 사용 중인 프레임워크입니다. 가상돔(Virtual DOM)이라는 특성을 갖고 있는 React와는 달리, Svelte는 빌드 단계에서 Svelte 코드를 바닐라 JavaScript 코드로 변환해주는 역할을 하며, 번들의 크기가 작고 반응형 프로그래밍에 특화되어 있습니다.
저는 이번 프로젝트에서 반응형 프로그래밍을 학습하며 개발하고자 하는 목표가 있었는데요. JavaScript와 유사한 문법을 가지고 있는 Svelte의 좋은 개발 경험을 직접 체험해 보니, Svelte를 선택한 것이 정말 잘한 선택이었다고 생각됩니다.
상태관리 with RxJS
상태관리는 RxJS라는 반응형 상태관리 라이브러리를 사용했습니다. 음성채팅의 경우 프로젝트의 사이즈가 상대적으로 크지 않지만, 그럼에도 다양한 시나리오에 대응해야 했기 때문에 반응형 프로그래밍을 직접 경험하고 배우기 적합하다고 생각했습니다. 특히, 저희 파트 Teo의 도움을 받아 adorable이라는 RxJS 기반의 라이브러리를 사용하여 조금 더 직관적으로 RxJS를 적용하여 반응형 프로그래밍을 경험할 수 있었는데요. 혹시 좀 더 관심이 있으시다면, if(kakao)dev2022에서 Teo가 발표했던 복잡한 상태관리도구 Svelte스럽게 만들기를 참고하시면 더 좋은 인사이트를 얻는 데에 도움이 될 것입니다.
반응형 프로그래밍 with RxJS
반응형 프로그래밍은 RxJS를 사용했습니다. 물론 Svelte에서도 반응형 문법을 지원하지만, 그럼에도 RxJS를 사용했던 이유는 비동기적 상태 관리와 시간을 다루는 다양한 오퍼레이터 때문이었습니다.
음성채팅의 경우 발화자의 음성 이퀄라이저 표시나 토스트 메시지 정책 등 시간을 다루는 로직들이 많고, 미디어를 생성 또는 변경하는 등 비동기적으로 이루어지는 이벤트들이 많이 발생합니다. 이런 점을 고려할 때, RxJS가 보다 효과적으로 이를 다룰 수 있다고 판단했습니다. 실제 RxJS에서 제공하는 다양한 오퍼레이터 중에서 debounceTime을 이용해 pending time을 주고, bufferCount를 이용해 변경 전후 상태값을 비교하여 처리하거나, combineLatest를 이용해 두 가지 이상의 상태값 변화를 관찰하기도 했는데요. 이런 오퍼레이터들을 직접 사용해 보니, 개발 생산성에 많은 도움이 되었습니다.
로컬 미디어 생성은 RxJS 기반의 반응형 상태관리 라이브러리인 adorable 라이브러리를 사용했습니다. adorable 라이브러리는 RxJS와 Redux의 장점만을 결합한 라이브러리로, 네이밍에서 알 수 있듯이 action을 dispatch 하면 reducer 안에서 On 메서드로 받습니다.
adorable = a(action), d(dispatch), o(on), r(reducer) + able
그럼 adorable을 사용해 음성채팅에서 로컬 미디어를 생성하는 과정을 코드와 함께 살펴보겠습니다.
// 로컬미디어 생성
dispatch(_로컬_미디어_생성.REQUEST, navigator.mediaDevices.getUserMedia({audio: true})
)
// 로컬 미디어에 대한 데이터 스트림 (별도 모듈로 분리)
export const localMedia$ = reducer(undefined, 'localMedia$', (localMedia$) => {
on(_로컬_미디어_생성.SUCCESS).writeTo(localMedia$)
})
// 로컬 미디어 생성 이후 서비스 로직들 (별도 모듈로 분리)
story('로컬 미디어 생성 완료 이후 이루어져야 할 로직들', () => {
localMedia$
.tap((media) => dispatch(_로컬_오디오_볼륨_측정(media.audio.getMediaStreamTrack())))
.tap((media) => dispatch(_마이크_장치_가져오기.REQUEST, media.getMicDevices()))
.tap((media) => dispatch(_스피커_장치_가져오기.REQUEST, media.getSpeakerDevices()))
.tap(() => 포스트메세지_웹_마이크_권한_획득완료())
.createEffect()
})
위 코드를 보면, 로컬 미디어 생성을 위한 ‘getUserMedia’ 메서드는 Promise를 리턴합니다.
이 Promise를 then/catch 구문을 이용해 pull 방식으로 한 모듈 안에서 다루게 되면, 흔히 얘기하는 callback hell에 빠질 수 있을뿐더러 데이터의 흐름을 파악하기 어렵습니다. 이 경우, async/await 구문을 이용하는 것도 좋지만, adorable을 사용하면 비동기 로직 처리 코드를 가독성 좋게 분리할 수 있습니다.
예를 들어, Promise를 _로컬_미디어_생성.REQUEST 라는 action의 형태로 dispatch 하고 resolve 후 then으로 이어지는 로직을 on(_로컬_미디어_생성.SUCCESS)에서 처리하도록 하여 코드를 분리시킬 수 있습니다. 이런 방식으로 로컬 미디어(localMedia$)에 대한 데이터 스트림을 별도 모듈로 분리하여 처리할 수 있으며, 이로 인한 부수 효과(side effect)들도 adorable의 story 구문을 이용하여 분리시킬 수 있습니다.
이렇게 제어의 역전이 이루어진 push 방식으로 데이터를 처리하게 되면, 데이터 스트림이 어떤 로직에 의해 변화되는지 추적하기 쉬워지고 추후 정책 변경이나 버그로 인해 수정해야 할 때 유지 보수 측면에서 굉장한 강점이 있습니다.
마이크 장치(micDevices$)에 대한 데이터 스트림도 위와 같이 reducer를 활용해 다룰 수 있습니다.
이렇게 특정 데이터 변화에 관련된 로직을 한곳에 모아 부수효과 없이 처리한다면 데이터의 변화를 파악하기 쉬워지고 디버깅을 할 때에도 상태 추적이 용이합니다.
export const micDevices$ = reducer([], 'micDevices$', (micDevices$) => {
on(_마이크_장치_가져오기.SUCCESS).writeTo(micDevices$)
on(_마이크_장치_가져오기.FAILURE)
.tap(() => ...)
.createEffect()
})
Mac OS의 웹뷰에서는 마이크 장치를 가져오는 도중에 입장음이 작게 들리는 이슈가 있었는데요. 저는 음성채팅에 입장을 완료했더라도, 마이크 장치를 가져오는 비동기 로직이 완료될 때까지를 기다려(waitFor) 다음 로직인 입장음을 재생하도록 하였습니다. 이렇듯 RxJS 오퍼레이터를 적절히 활용하니 타이밍 관리도 쉽게 할 수 있었습니다.
on(_음성채팅_입장완료)
.waitFor(micDevices$)
.tap(() => playSound(SOUND_FILE_URLS.entrance))
.createEffect()
메인 스피커 판단 로직
음성대화에 있어 주발화자가 누구인지 판단하는 메인스피커 판단 로직은 음성채팅 UX에서 중요한 부분입니다. 음성채팅에 입장이 완료되면 사용자들의 음성을 주기적으로 가져와 이를 판단하도록 하였습니다.
// 참여자들의 음성 볼륨
export const remoteAudioLevelMeter$ = reducer([], '#remoteAudioLevelMeter$', (remoteAudioLevelMeter$) => {
Observable.timer(0, VOLUME_METER_TIMER)
.waitFor(isConnected$)
.map(() => getParticipanstAudioLevels())
.writeTo(remoteAudioLevelMeter$)
})
또한, 미디어 서버로부터 받은 다른 참여자들의 음성 볼륨과 로컬에서 측정한 내 음성 볼륨을 리스트에 넣고 이를 볼륨이 높은 순으로 정렬하여, 객체 형태로 speakers$ 스트림에 저장하였습니다.
// 발화자들 정보
export const speakers$ = reducer<Speakers>({}, '#speaker$', (speakers$) => {
remoteAudioLevelMeter$
.map((levels) => levels.map((l) => ({id: l.remoteParticipant.id, level: l.level / 250})))
.map((levels) => {
const others = levels.filter((l) => l.level > SPEAK_VOLUME_LEVEL)
const me =
localAudioLevelMeter$.value > SPEAK_VOLUME_LEVEL
? [{level: localAudioLevelMeter$.value, id}]
: []
return [...others, ...me].slice(0, 4)
})
.map((levels) => levels.sort((a, b) => b.level - a.level))
.map((levels) =>
levels.reduce((acc, cur) => {
acc[cur.id] = {id: cur.id, level: cur.level}
return acc
}, {})
)
.writeTo(speakers$)
})
메인스피커(mainSpeaker$)는 발화자(speakers$) 중에서 가장 큰 음성 볼륨으로 말하는 자를 판단하도록 하였습니다. 또한, RxJS의 debounceTime 오퍼레이터를 활용하여 메인스피커가 업데이트된 후에도 일정 pending 시간 동안 발화중 상태를 유지시켜 발화가 지속되는지 여부를 판단하도록 했습니다.
// 메인 스피커 정보
export const mainSpeaker$ = reducer<Participant>(undefined, '#mainSpeaker$', (mainSpeaker$) => {
speakers$
.filter((speakers) => Object.keys(speakers).length)
.map((speakers) => Object.values(speakers)[0])
.writeTo(mainSpeaker$)
})
// 메인 스피커 발화중 상태
export const isMainSpeaking$ = reducer<Boolean>(false, 'isMainSpeaking$', (isMainSpeaking$) => {
mainSpeaker$
.writeTo(isMainSpeaking$, true)
.debounceTime(SPEAK_PENDING_TIME)
.writeTo(isMainSpeaking$, false)
})
그렇게 판단한 메인스피커 정보는 view 단에서 아래와 같은 형태로 적용되어 보여집니다.
<script>
// 메인스피커 정보
$: userID = $mainSpeaker$?.userID
$: displayName = $userMap$[userID]?.display_name ?? ''
$: imageUrl = $userMap$[userID]?.avatar_url
$: level = $speakers$[$mainSpeaker$?.voiceChatUserID]?.level
</script>
<div class="main-speaker">
<Kakao__Profile_Thumbnail {imageUrl} />
<MainSpeaker_DisplayName {displayName} />
<MainSpeaker_Level {level} />
</div>
앱과의 인터페이스
웹뷰에서는 네이티브 쪽으로 데이터를 전달하거나 네이티브 화면을 컨트롤해야 하거나 네이티브 객체로부터 데이터를 전달받는 등의 통신이 필요합니다. 따라서 웹뷰와 앱 간의 통신 인터페이스가 필요한데, 음성채팅에서는 3가지의 앱과의 통신 방식 (post message, js call, app scheme)을 활용하였습니다.
다만, 저는 이러한 인터페이스들의 구현보다 중요한 것은 필요한 인터페이스의 명칭이나 파라미터를 정확하게 정의하고 커뮤니케이션을 원활하게 하는 것이라고 생각하는데요. 이를 위해 현재 구현된 인터페이스를 확인할 수 있는 테스트 환경이나 문서화, 그리고 잦은 티타임(?)을 추천합니다.
① 포스트 메시지 (post message)
포스트 메시지는 웹뷰에서 네이티브 쪽으로 데이터를 전송하거나 특정 액션을 요청할 때 사용하였습니다. 네트워크를 통하는 것이 아니라 직접 함수를 호출하는 방식이기 때문에 네트워크 에러로 인해 데이터가 전송되지 않을 염려가 없습니다. 사용자가 웹뷰에서 특정 버튼을 눌렀을 때 환경설정으로 이동시키거나 특정 채팅방을 띄운다거나 입장 여부를 알려서 네이티브 상태를 업데이트하는 등의 경우에 주로 사용하였습니다.
음성채팅 PC 버전은 Windows와 Mac을 대응하였기 때문에 각 웹뷰 객체에서 지원하는 postMessage를 사용하였습니다. 다행히도 웹뷰 객체에서 지원하는 postMessage 메서드는 JSON 형태의 데이터 전송을 지원하기 때문에 따로 직렬화(serialize)를 해서 보내줄 필요가 없습니다.
// Mac (swift)
window.webkit.messageHandlers[Interface명].postMessage(data)
// window (c#)
window.chrome.webview.postMessage({Interface: [Interface명], data})
② 전역 함수 호출 (js call)
네이티브로부터 데이터를 전달받을 때는 jsCall 방식으로 전역함수를 심어놓고 데이터를 넘겨받았습니다. 카카오워크 음성채팅은 네이티브에서 구현된 소켓을 통해 실시간 참가자 정보를 관리하므로 네이티브앱으로부터 참가자 정보나 현재 유저 정보에 대한 데이터를 전달받을 때 사용하였습니다.
const onParticipantEntered = (data: Data) => {
로그_추가(`[onParticipantEntered] ${JSON.stringify(data, null, 2)}`)
dispatch(_멤버_입장({...data}))
}
const onParticipantLeft = (data: Data) => {
로그_추가(`[onParticipantLeft] ${JSON.stringify(data, null, 2)}`)
dispatch(_멤버_퇴장({...data}))
}
export const createJsCall = () => {
const jsFns = {
onParticipantEntered, onParticipantLeft, ...
}
Object.entries(jsFns).forEach(([name, func]) => {
Object.defineProperty(window, name, {
value: func,
writable: false
})
}
③ 앱스킴 (app scheme)
앱스킴은 기존 카카오워크에 구현되어 있는 앱스킴들을 최대한 활용하였습니다. 이는 커뮤니케이션 및 개발 리소스를 줄이는 데 도움이 되었습니다. 예를 들어, 특정 웹뷰의 창을 닫는 기존 카카오워크의 앱스킴을 그대로 적용하여 웹뷰에서 나가기 버튼을 클릭하였을 때 트리거 되도록 하였습니다.
// 창 닫기 앱스킴
export const 앱스킴_창_닫기 = () => (window.location.href = "app://close")
// 나가기 버튼
<Button on:click={() => 앱스킴_창_닫기()}>나가기</Button>
음성채팅 개발 과정
그럼 이번에는 음성채팅 개발 과정에 대해 살펴보겠습니다. 전체적인 개발 흐름은 아래와 같은데요. 여기서 주의해야 할 것들 위주로 개발 과정을 설명해 보겠습니다.
① 로그인
로그인 메서드는 Promise를 리턴합니다. 로그인 과정이 완료되어야 이후 다른 메서드들이 정상 동작하므로 다음 로직이 동기적으로 이루어질 수 있도록 처리가 필요합니다. 저는 async/await 구문을 활용했습니다.
// 1. 로그인
try {
await login()
로그_추가('User Signed In')
} catch (e) {
로그_추가(`Failed to Sign => ${e}`)
dispatch(_API_에러('Failed to Sign'))
}
② 로컬 미디어 생성 및 미디어 서버 연결
로그인을 하고 나면, 로컬 미디어를 생성하고 미디어 서버 연결 과정이 필요합니다. 여기서는 이 과정을 추상화한 Channel이라는 객체로 설명하겠습니다. 채널 ID를 key로 하여 미디어 서버의 특정 채널에 접속이 완료되면 그때부터 미디어 서버로부터 채널에 참여한 다른 참여자 입장/퇴장, 미디어 송출 등 다양한 이벤트를 구독할 수 있습니다. 카카오워크 음성채팅의 경우 참여자 목록을 따로 관리하였기 때문에, 참여자 관련 이벤트는 구독하지 않았고 미디어 서버 연결 에러 이벤트인 'error'만 구독하여 처리하였습니다.
// Channel 생성
const createChannel = () => {
const channel = SDK.createChannel()
로그_추가('Channel created')
if (!channel) throw new Error('Fail to Create Channel')
channel$.set(channel)
// 미디어 서버 연결 에러 이벤트
channel.on('error', (event) => {
channel$.set(channel)
dispatch(_API_에러('media server disconnected'))
})
return channel
}
// Channel 생성
const channel = createChannel()
// Channel 접속
dispatch(_채널_접속.REQUEST, channel.connect(Id))
on(_채널_접속.SUCCESS)
.tap(() => {
const {id} = channel.local
로그_추가('Channel connected')
dispatch(_음성채팅_입장완료({id}))
})
.createEffect()
on(_채널_접속.FAILURE)
.tap((e) => {
로그_추가(`Failed to connect channel => ${e}`)
dispatch(_API_에러('Failed to connect channel'))
})
.createEffect()
// Channel 접속 완료 여부
export const isConnected$ = reducer(false, 'isConnected$', (isConnected$) => {
on(_음성채팅_입장완료).writeTo(isConnected$, true)
})
③ 로컬 미디어 정보 업데이트 및 미디어 송출
로컬 미디어 생성이 완료되면, 로컬 미디어 정보를 미디어 서버에 업데이트하기 위해 미디어를 송출하는 로직이 필요합니다. 다시 말해, 로컬미디어를 생성하였더라도 미디어 송출 과정이 완료되어야 미디어 서버를 통해 다른 참여자에게 로컬 미디어가 공유되는 점에 유의해야 합니다. 미디어 송출 과정이 없으면 로컬 미디어가 생성되었더라도 다른 참여자에게 음성이 들리지 않을 수 있습니다.
또한 미디어 송출을 위해서는 로컬 미디어 생성뿐만 아니라 채널 접속(미디어 서버 연결)도 선행되어야 하므로, 두 가지의 Promise가 모두 resolve된 후 미디어 송출 과정이 이루어져야 합니다. 따라서 저는 Promise.all과 유사한 구문인 RxJS의 combineLatest 오퍼레이터를 사용하여 이를 처리했습니다.
// 로컬미디어 생성
dispatch(_로컬_미디어_생성.REQUEST, navigator.mediaDevices.getUserMedia({audio: true})
// 로컬미디어
export const localMedia$ = reducer(undefined, 'localMedia$', (localMedia$) => {
on(_로컬_미디어_생성.SUCCESS).writeTo(localMedia$)
})
on(_로컬_미디어_생성.FAILURE)
.tap((e) => 로그_추가(`Failed to get user media => ${e}`))
.createEffect()
// 로컬미디어가 생성 및 Channel 접속이 완료되면 미디어 송출(publish)
combineLatest([localMedia$, isConnected$])
.tap(([localMedia, isConnected]) => {
if (localMedia) dispatch(_로컬_미디어_송출.REQUEST, channel.publish([localMedia]))
})
.createEffect()
on(_로컬_미디어_송출.SUCCESS)
.tap(() => 로그_추가('Local media published'))
.createEffect()
on(_로컬_미디어_송출.FAILURE)
.tap((e) => 로그_추가(`Failed to publish => ${e}`))
.createEffect()
}
전체 코드는 아래와 같습니다.
// 음성채팅 접속
export const connectVoiceChat = async (roomId: string, userID: number) => {
// 1. 로그인
try {
await login()
로그_추가('User Signed In')
} catch (e) {
로그_추가(`Failed to Sign => ${e}`)
dispatch(_API_에러('Failed to Sign'))
}
// 2. Channel 생성
const channel = createChannel()
// 3. Channel 접속
dispatch(_채널_접속.REQUEST, channel.connect(Id))
on(_채널_접속.SUCCESS)
.tap(() => {
const {id} = channel.local
로그_추가('Channel connected')
dispatch(_음성채팅_입장완료({id}))
})
.createEffect()
on(_채널_접속.FAILURE)
.tap((e) => {
로그_추가(`Failed to connect channel => ${e}`)
dispatch(_API_에러('Failed to connect channel'))
})
.createEffect()
// 4.로컬미디어 생성
dispatch(_로컬_미디어_생성.REQUEST, navigator.mediaDevices.getUserMedia({audio: true})
on(_로컬_미디어_생성.FAILURE)
.tap((e) => 로그_추가(`Failed to get user media => ${e}`))
.createEffect()
// 5. 로컬미디어가 생성 및 Channel 접속이 완료되면 미디어 송출(publish)
combineLatest([localMedia$, isConnected$])
.tap(([localMedia, isConnected]) => {
if (localMedia) dispatch(_로컬_미디어_송출.REQUEST, channel.publish([localMedia]))
})
.createEffect()
on(_로컬_미디어_송출.SUCCESS)
.tap(() => 로그_추가('Local media published'))
.createEffect()
on(_로컬_미디어_송출.FAILURE)
.tap((e) => 로그_추가(`Failed to publish => ${e}`))
.createEffect()
}
// Channel 접속 완료 여부
export const isConnected$ = reducer(false, 'isConnected$', (isConnected$) => {
on(_음성채팅_입장완료).writeTo(isConnected$, true)
})
// 로컬미디어
export const localMedia$ = reducer(undefined, 'localMedia$', (localMedia$) => {
on(_로컬_미디어_생성.SUCCESS).writeTo(localMedia$)
})
어려움을 겪었던 부분들
음성채팅을 구현하면서 어려웠던 점들도 많았는데, 한번 정리해 보도록 하겠습니다.
웹뷰의 한계
사실 처음에는 음성이 잘 전달될지, 상태 관리가 미숙하지 않을지, 앱과의 인터페이스가 잘 동작할지에 대한 걱정을 했었습니다. 그러나 예상과 달리 문제는 다른 곳에 있었습니다…
Mac OS의 웹뷰는 OS 버전에 종속적인 wkwebview를 사용합니다. 따라서 OS 버전에 따라 웹뷰 브라우저의 버전도 달라집니다. Mac의 Bigsur 버전에서는 로컬 미디어를 생성할 때 마이크 권한을 두 번(네이티브 권한 + 웹자체 권한) 묻습니다. 권한 팝업이 중복해서 두 번 뜨는 것도 문제지만, 웹 자체 마이크 권한 같은 경우는 한 번 허용해도 재진입 시 계속해서 권한을 묻는 문제가 있습니다.
또한, 로컬 미디어의 볼륨을 측정 시 문제도 있었습니다. Mac의 Bigsur버전에서는 AudioWorkletNode라는 객체가 작동하지 않는데요. 따라서 createScriptProcessor를 통해 볼륨측정 코드를 구현해야 했고, 이마저도 Bigsur 11.2 버전 이하에서는 동작하지 않습니다.
이렇듯 웹뷰 객체는 Safari나 Chrome과 완전히 같은 브라우저는 아니기 때문에 개발 환경에서 잘 동작하는 것들이 버전에 따라 동작하지 않을 수 있는 한계가 있습니다.
디버깅
따로 테스트 환경을 구축하지 않으면 정상 동작을 확인하기 위해 네이티브 앱에 웹뷰를 올려본 후 확인해야 하는 번거로움이 있습니다. 개인적으로 저는 이번 프로젝트에서는 사전에 별도 테스트 환경을 구축하지 못하고 개발을 진행하였지만, 네이티브 개발자를 위한 테스트 웹페이지를 구축하거나 웹 개발자를 위한 테스팅 앱을 사전에 개발하여 테스트 환경을 미리 구축하는 것이 필요하다고 생각합니다.
특히 웹뷰를 구현할 때에는 디버깅의 편의를 위해 로그를 잘 심어야 한다고 생각하는데요. 앱과 웹뷰가 긴밀하게 소통하기 때문에 버그가 발생했을 때 어느 부분에서 발생한 이슈인지 파악하는게 어려울 수 있고 여기에 불필요한 리소스가 소요될 수 있기 때문입니다.
// 디버깅 로그 추가
export const 로그_추가 = (str) => {
// 웹브라우저 콘솔 확인용
if (!isProduction) console.log(str)
// 네이티브 콘솔 확인용
if (isMac) window.webkit.messageHandlers.addLog.postMessage({log: str})
else if (isWindows) window.chrome.webview.postMessage({Interface: 'addLog', log: str})
}
클라이언트별 특징
마지막으로 OS별 클라이언트의 특징을 고려해야할 필요가 있습니다. Mac OS의 경우 네이티브 권한을 재설정하면 앱을 재시작할 때까지 설정값이 앱에 반영되지 않습니다. 하지만 Windows의 경우 환경설정에서 마이크 권한을 변경하면 즉각 앱에 이러한 설정이 반영됩니다. 따라서 Windows에서는 사용자 임의의 마이크 권한 변경을 감지할 수 있도록 주기적으로 마이크 권한 여부를 polling하여 변경 여부를 판단하는 로직을 추가해야 했습니다.
또한 로컬미디어 장치에 허용도 OS별로 다릅니다. Windows의 웹뷰에서는 로컬 미디어의 스피커 정보를 가져오고 변경하는 것이 가능합니다. 하지만 Mac의 WKWebView의 경우 스피커 정보를 가져오거나 스피커 기기를 변경하는 것이 불가능하며 시스템 기본 스피커로 고정되므로 이에 맞는 처리가 필요합니다.
마치며
음성채팅은 제가 카카오엔터프라이즈에 입사한 이후 맡은 첫 대외오픈 프로젝트입니다. 저에게 익숙하고 편했던 방식을 내려놓고, 새로운 기술적 시도를 하느라 고군분투했던 프로젝트이기도 합니다. 이 프로젝트를 통해 WebRTC, 반응형 프로그래밍 패러다임, 그리고 PC 웹뷰의 인터페이스 등을 배우고 적용하며 저 스스로도 많은 성장을 했다고 생각합니다. 다양한 내용을 담으려다 보니 다소 두서가 없지만, 제 짧은 글이 누군가에게 작은 도움이라도 되었으면 하는 바람입니다.
음성채팅 기능은 지속적으로 발전중에 있습니다. 향후에는 간편하게 창을 띄워 음성대화를 할 수 있는 것뿐 아니라 화면 공유 등 업무 커뮤니케이션을 위한 기능들을 추가할 예정입니다.
코로나를 겪으며 우리 생활은 많은 것들이 바뀌었습니다. 멀리 떨어져 있어도 함께 일하고 대화하는 것들이 이제는 더이상 어려운 것이 아닙니다. 그렇기 때문에 카카오워크 팀에서는 앞으로도 음성채팅뿐만 아니라 다양한 업무 환경에 대응할 수 있는 여러 기능을 제공하기 위해 지속적으로 노력할 것입니다.
다음 포스팅에서는 더 알차고 신박한 꿀팁과 함께 다시 만날 수 있었으면 좋겠습니다. 읽어주셔서 감사합니다~!
댓글