적용한 것들
- input device를 변경하는 컴포넌트 작성
- 클래스 인스턴스 내에서 음성채팅 로직 구현하기
- 멤버 리스트 띄우고 오디오 설정하기(map함수 + useState, useRef two track custom hook)
- 문자 채팅하기 (채팅창 css세팅 + setState 함수형 업데이트)
- 유튜브 동영상 예약하기
async함수로 구현 한 setMedia는 deviceID를 통해 장치 변경이 가능하고, localGainNode.gain.value를 조작하면 음량도 변경할 수 있다. Me 클래스에 구현했다.
user.js
class Me extends User{
socket = io.connect(ENDPOINT);
deviceId = "default";
localAudioCtx = new AudioContext();
localGainNode = this.localAudioCtx.createGain();
localDestination = this.localAudioCtx.createMediaStreamDestination();
constructor(userIcon, nickname){
super(userIcon, nickname);
this.socket.emit("getNickname", this.nickname, this.userIcon)
this.socket.on('connect', ()=>{this.ID = this.socket.id;})
this.setMedia();
}
joinRoom(roomname){
this.socket.emit('joinRoom',roomname)
this.roomInfo = roomname;
}
async setMedia(deviceID="default"){
navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, deviceId:{exact: deviceID} },
video: false
}).then((stream)=>{
this.localSource = this.localAudioCtx.createMediaStreamSource(stream);
this.localSource.connect(this.localGainNode);
this.localGainNode.connect(this.localDestination);
this.localGainNode.gain.value = 0.5;
})
this.deviceId = deviceID ;
}
setLocalAudio(ref){
ref.srcObject = this.localDestination.stream;
ref.play();
}
setLocalVolume(volume){
this.localGainNode.gain.value = volume;
}
}
리팩토링 이전에는 페이지 컴포넌트 내에서 음성채팅 로직을 작업했다. 그 내용이 상당히 길었고, 중복되는 코드량이 상당했다. 음성채팅에 대한 로직을 몰랐던 팀원들도 분명 이 방대한 코드 때문에 불편함이 많았을 것이다ㅠㅠ 상대방에 대한 로직을 클래스로 구현하고 그를 상속하여 '나'를 정의하면 로직을 훌륭하게 재사용할 수 있다. 만약 팀플에서 이런 방식을 사용했다면 팀원들이 보는 코드의 복잡성을 상당히 줄였을 것이다.
user.js
class User{
host = false;
roomInfo;
mediaStream;
connection;
audioRef;
constructor(userIcon, nickname, ID){
this.userIcon = userIcon;
this.nickname = nickname;
this.ID = ID;
}
setConnection(me, join){
console.log('setconnection', this.ID, join)
this.connection = new RTCPeerConnection({
iceServers: [
{
urls: [
"stun:stun1.l.google.com:19302",
"stun:stun2.l.google.com:19302",
"stun:stun3.l.google.com:19302",
"stun:stun4.l.google.com:19302",
]
}
]
})
me.localDestination.stream.getTracks().forEach(track =>{
this.connection.addTrack(track, me.localDestination.stream)
})
if(join){
this.connection.createOffer()
.then((result)=>{
this.connection.setLocalDescription(result)
me.socket.emit("offer", result, this.ID)
})
}
this.connection.addEventListener("icecandidate", (ice)=>{
console.log(ice)
me.socket.emit("ice", ice.candidate, this.ID )
})
this.connection.addEventListener("addstream", (data)=>{
this.mediaStream = data.stream
this.audioRef.srcObject = this.mediaStream
this.audioRef.play();
})
}
setOffer(me, offer){
this.connection.setRemoteDescription(offer);
this.connection.createAnswer()
.then((result)=>{
this.connection.setLocalDescription(result);
me.socket.emit("answer", result, this.ID);
})
}
setAnswer(answer){
this.connection.setRemoteDescription(answer);
}
setIce(ice){
this.connection.addIceCandidate(ice);
}
setVolume(volume){
this.audioRef.volume = volume;
}
}
map함수를 이용한 렌더링은 기본적인 거라 배열만 넘겨주면 고맙게도 팀원들이 곧잘 구현을 해 주었다. 기존의 코드는 유저와 오디오 커넥션을 따로 관리했다. 심지어 오디오 커넥션 배열은 전역변수로 관리되어 채팅방을 나갈때마다 비우며 리액트 라이프사이클을 넘나들었다ㅋㅋ
상태관리에 대해 연구하다보면 useState vs useRef vs variable이 헷갈릴 때가 반드시 온다... useState와 useRef의 차이는 렌더링 유무이고 useRef vs variable의 차이는 리액트 라이프사이클 내에서 관리되는가 이다. 변수를 컴포넌트 안에 선언하면 리렌더링 할 때마다 초기화된다. 그래서 만약 전역변수로 컴포넌트 밖에 선언한다면, 컴포넌트가 언마운트 되어도 변수가 남아 다시 마운트했을때 값이 살아있다. 물론 언마운트 시 변수를 초기화 시켜버리면 useRef와 거의 똑같이 사용가능하지 않나 싶다. 물론 useRef가 범용성에서 훨씬 나은 것 같지만.
그래서 전역변수로 해결하던 상태관리를 모두 리액트 훅으로 가져오는 작업을 해줬다. 유저가 채팅방에 들어오면 유저에 대한 클래스 인스턴스를 생성하기 위해 클래스를 작성해놨었다. 클래스 메소드가 있는 인스턴스들은 결론적으로 useState로 관리하면 불변성에 정확히 위배되기에 useRef로 관리하는게 정석으로 보인다. 하지만 유저가 생성될 때 마다 리렌더링은 해야되는데..... 결국 mutable state관리라는 신박한 과제가 주어졌다.
내가 내놓은 해결책은 useState, useRef를 모두 사용하는 custom hook을 하나 제작하는 것이었다. 유저가 채팅방에 접속하는 이벤트가 발생하면 유저 인스턴스는 useRef에 저장하고, 유저의 아이콘과 닉네임은 useState에 저장한다. 이 때 비동기 함수로 작성될 시 setState가 먼저 실행되어 유저 인스턴스 메소드(음성연결)를 실행할 수 없게 된다. async함수로 useRef.current.push 후 setState가 작동하도록 했고, 오류가 해결되었다.
useMember.js
import{useState, useRef} from "react";
import {User} from './user'
export default function useMember(initialUser){
const [memberState, setMembers] = useState([initialUser.ID]);
const memberRef = useRef([initialUser]);
const pushRef = async(newUser, user, joined)=>{
newUser.setConnection(user, joined)
memberRef.current.push(newUser)
return memberRef
}
const setMember = async(memberData,user,joined) => {
const idList = memberRef.current.map(x => x.ID)
const dataIdList = memberData.map(x=>x.id)
console.log(idList, dataIdList)
if(idList.length<dataIdList.length){
for(const member of memberData){
console.log(!(member.id in idList) && member.id != user.ID)
if(!(member.id in idList) && member.id != user.ID ){
await pushRef(new User(member.icon, member.nickname, member.id),user, joined)
console.log(memberRef.current[memberRef.current.length-1])
setMembers(dataIdList)
}
}
}else if(idList.length>dataIdList.length){
for(const member of memberRef.current){
if(!dataIdList.includes(member.ID)){
console.log(memberRef.current,dataIdList,String(member.ID) )
member.connection.close();
memberRef.current.splice(memberRef.current.indexOf(member),1)
console.log(dataIdList)
setMembers(dataIdList)
}
}
}
}
return [memberState, memberRef, setMember]
}
이전엔 그냥 줄글로만 나오던 채팅창을 좀 더 채팅창 처럼 만들고자 했다. socket이벤트에 의해 채팅 메시지를 받으면 setState를 통해 업데이트를 하는데,
1. setChattings(chattings.concat(content)) 2. setChattings((chattings)=>chattings.concat(content))
1 처럼 함수형 업데이트를 사용하지 않으면 채팅 목록이 쌓이지 않았다.
또한 pre태그를 통해 채팅 내역을 표시했는데, 텍스트가 width를 넘어가면 자동 줄바꿈이 되도록 하려했다. 정말 웃긴 건 해당 효과를 위한 방법이 검색하면 두 가지 나오는데,
1. white-space: pre-line; 2. word-break: break-all;
적어도 크로미움에서 1 은 한글에만 유효했고, 2 는 영어에만 유효했다..... 따라서 두개를 다 넣어주니까 해결됐다.
이게 뭔지랄이여
또 퍼센테이지로 분배된 블럭에서 overflow속성을 사용하면 부모요소를 자꾸 건드린다....마음에 안들지만 결국 절대값을 줘서 해결했는데 이거 무슨방법을 써야하는지 궁금하다.... 퍼센트 기반으로 나누는 flex기반으로 컨테이너 구성을 했는데 flex컨테이너에서 overflow처리하는게 너무 까다롭다. 그런데 검색해도 잘 모르겠다ㅠㅜ
대망의 마지막 동영상 예약이다. 유튜브url을 입력하면 노래가 예약되고, 예약 순서대로 재생된다. input type을 url로 설정하면 1차적으로 input 유효성 검사가 가능하고, oEmbed포맷을 이용하면 해당 url이 유튜브 영상이 맞는지, 영상의 저작권자가 iframe 삽입을 허용했는지 등 2차적으로 유효성 검사를 할 수 있다.
youtube iframe API <- 또한 여기서 iframe 플레이어 인스턴스 생성 및 메소드들을 파악할 수 있다. 무엇보다 쉽다.
이전에 아쉽게도 구현하지 못한 유튜브 영상의 볼륨조절 같은 것들을 추가하는 것으로 리팩토링을 마쳤다. 여유가 많지는 않아 렌더링 최적화라던가 하는 과제들이 남겨져 있지만, 다른 프로젝트에서 열심히 해보기로 했다. 해당 프로젝트에서도 리렌더링 시 오류방지를 위해 React.memo등의 코드가 일부 들어있기는 하다.
Socket.io에 대해서도 사실은 이 프로젝트하면서 정말 많이 익숙해졌는데, 리팩토링하면서 socket코드는 거의 손대지 않아서 작성하진 않았다. 노마드 코더의 zoom 클론코딩 영상이 계기가 되어 시작하게되었는데, 니꼬샘 영상이 정말 도움이 많이 되었다 ㅠㅠ socket통신과 webRTC시그널링을 담당하는 서버는 그래도 팀원 한분이 많이 분담을 해주셔서 모든 기능을 거의 다 커버했다. express-generator를 통해 프로젝트 생성을 하고, www파일만 고쳐서 만들었는데, 이것 또한 괜찮게 된 것 같다.
이제 이걸 통해 정말 내가 하고싶었던 Riot API를 사용한 앱들도 쉽게 만들어볼 수 있을 것 같다. 빌드도 해보고싶었는데 이상하게 윈도우 환경에서는 다 문제가 생겨서 일단은 넘어가려한다(리눅스는 잘되는데ㅠㅠ)
최종완성 시연