기존에 진행한 프로젝트인 화상회의 스터디 플랫폼의 핵심 기능은 화상회의였다. 따라서 화상회의를 관리하는 세션 페이지 컴포넌트에 모든 로직이 집중되어 있었다.
세션 페이지라는 컴포넌트는 엄청나게 많은 일을 처리하게 되었고 코드가 길어 테스트 코드를 도입한 후 분리하는 리팩토링을 진행하였지만 아쉬운 점이 많았다.
문제를 정리하기 전 props drilling 문제가 주가 되기 때문에 props drilling에 대해서 간단히 정리해보았다.
props drilling은 상위 컴포넌트에서 데이터를 여러 계층의 하위 컴포넌트로 전달하기 위해 중간에 있는 컴포넌트를 통해 props를 계속 drilling 하듯이 전달하는 과정을 말한다.
이렇게 전달하는 구조의 문제점은 무엇일까?
props drilling을 해결하기 위해 Context API를 사용해 중간 컴포넌트를 거치지 않고 데이터를 공유할 수 있다. 아니면 전역 상태 관리 라이브러리를 사용해 전역 상태로 관리할 수도 있다.

스터디를 화상회의로 진행하는게 서비스의 핵심이었고, 화상회의를 담당하는 페이지가 세션 페이지였다. (최근 리팩토링을 통해 이름을 세션이 아닌 좀 더 직관적인 (스터디)채널로 변경했다.)
해당 세션(채널) 페이지의 구조는 위 그림과 같았다.
📌 문제라고 생각했던 부분
- 세션 페이지를 이루는 컴포넌트에게 props로 전달하는 값이 굉장히 많았고, 상태 뿐만 아니라 상태 변경 함수도 전달하기 때문에 더 복잡했다.
- 실제로 전달되는 props는 바로 하위 컴포넌트가 아닌 계속해서 하위 컴포넌트로 전달하며 중간 컴포넌트에서는 해당 props를 사용하지 않기도 했다.
- 결과적으로 useSession이라는 훅에서 반환받은 값을 props로 전달하는데, 이 구조도 문제가 있는데, 훅에 대해서는 아래에서 더 자세히 다룰 예정이다.

최종적으로 UI 부분만 보면 코드 자체는 위와 같았다. 이렇게 작성된 코드에서 내가 문제라고 느낀 부분은 다음과 같았다.
반면 이렇게 구현했을 때의 장점도 있었다.
- 상태 자체를 컴포넌트 상위에서 관리하고 useState로 관리해주었기 때문에 컴포넌트에서 상태를 관리하는 것이 생각보다 간단했다.
- 간단하다는 것의 의미는 해당 컴포넌트가 언마운트 되거나 하는 일이 있을 때 상태가 자동으로 정리되기 때문에 특별히 관리해줄 부분이 없었다.
전반적으로 장점보다는 수정이 쉽지 않은 구조와 수정을 하지 않더라도 흐름 파악이 어려운 코드이기 때문에 리팩토링이 필요하다 느꼈다.

세션 페이지에서 사용되는 훅은 위와 같았다.
useAudioDetector: 로컬 사용자와 연결된 피어들의 오디오 상태를 감지하는 훅useBlockNavigate: 화상회의 페이지에서 새로고침 시 바로 나가지지 않고 알림창을 띄워주는 훅useMediaDevices: 사용자의 미디어 디바이스를 가져오거나 그 외의 미디어 관련 처리를 하는 훅useMediaStreamCleanUp: 미디어 스트림 클린 업 훅usePeerConnection: 피어 간 연결을 처리하는 훅usePeerConnectionCleanUp: 피어 커넥션을 정리하는 훅useReaction: 리액션 관련 처리를 해주는 훅useStudy: 스터디 채널(화상회의)에서 스터디를 시작하고 종료하는 등과 관련된 로직을 처리하는 훅useSocketEvents: 모든 소켓 이벤트를 등록하고 해제하는 훅useSession: 위의 대부분 훅을 호출하며 최종적으로 세션 페이지 컴포넌트에서 호출하게 되는 훅useSession 훅 코드의 일부를 보면 다음과 같다.
export const useSession = (sessionId: string) => {
const { socket } = useSocket();
const toast = useToast();
const {
createPeerConnection,
closePeerConnection,
peers,
setPeers,
peerConnections,
dataChannels,
peerMediaStatus,
} = usePeerConnection(socket!);
const { nickname: username } = useAuth();
const [nickname, setNickname] = useState<string>("");
const [roomMetadata, setRoomMetadata] = useState<RoomMetadata | null>(null);
const [isHost, setIsHost] = useState<boolean>(false);
const mediaPreviewModal = useModal();
const [ready, setReady] = useState(false);
useEffect(() => {
mediaPreviewModal.openModal();
}, []);
const {
userVideoDevices,
userAudioDevices,
selectedAudioDeviceId,
selectedVideoDeviceId,
stream,
isVideoOn,
isMicOn,
setIsVideoOn,
handleMicToggle,
handleVideoToggle,
setSelectedAudioDeviceId,
setSelectedVideoDeviceId,
getMedia,
getMediaStream,
videoLoading,
} = useMediaDevices(dataChannels);
const { setShouldBlock } = useBlockNavigate();
useEffect(() => {
if (username) {
setNickname(username);
}
}, [setNickname, username]);
useEffect(() => {
if (selectedAudioDeviceId || selectedVideoDeviceId) {
getMedia();
}
}, [selectedAudioDeviceId, selectedVideoDeviceId, getMedia]);
usePeerConnectionCleanup(peerConnections);
useMediaStreamCleanup(stream);
const { reaction, emitReaction, handleReaction } = useReaction(
socket,
sessionId,
setPeers
);
const { requestChangeIndex, stopStudySession, startStudySession } = useStudy(
socket,
isHost,
sessionId,
roomMetadata
);
useSocketEvents({
socket,
stream,
nickname,
sessionId,
createPeerConnection,
closePeerConnection,
peerConnections,
setPeers,
setIsHost,
setRoomMetadata,
handleReaction,
setShouldBlock,
});
const joinRoom = async () => {
...
socket.emit(SESSION_EMIT_EVENT.JOIN, { roomId: sessionId, nickname });
};
const participants: Participant[] = useMemo(
() => [
{ nickname, isHost },
...peers.map((peer) => ({
nickname: peer.peerNickname,
isHost: peer.isHost || false,
})),
],
[nickname, isHost, peers]
);
return {
nickname,
setNickname,
reaction,
peers,
peerConnections,
userVideoDevices,
userAudioDevices,
isVideoOn,
isMicOn,
setIsVideoOn,
stream,
roomMetadata,
isHost,
participants,
handleMicToggle: () => handleMicToggle(),
handleVideoToggle: () => handleVideoToggle(peerConnections.current),
setSelectedAudioDeviceId,
setSelectedVideoDeviceId,
joinRoom,
emitReaction,
videoLoading,
peerMediaStatus,
requestChangeIndex,
startStudySession,
stopStudySession,
getMedia,
getMediaStream,
mediaPreviewModal,
ready,
setReady,
setShouldBlock,
};
};
useSession에서 다양한 훅을 모두 호출하게 되고 최종적으로 모든 상태를 return하면 세션 페이지 컴포넌트가 이 훅의 반환 값을 하위 컴포넌트로 전달해주는 형태이다.
처음 코드가 길어 기능 별로 훅을 분리해서 코드 길이를 줄이고 기능 별로 파악하기 좋다고 느꼈지만, 수정하고 상태 흐름을 추적하려고 코드를 다시 살펴보니 많은 문제가 있었다. 내가 생각한 문제는 다음과 같다.
useSocketEvents 훅에서도 모든 이벤트를 등록하는데, 이때 이벤트 핸들러는 피어커넥션을 생성, 종료하는 로직도 필요하고 그 외에도 다른 훅에서 사용하는 상태나 로직이 필요하다.위에서 작성했듯이 현재 화상회의를 진행하는 페이지에서의 문제는 정리하면 다음과 같다.
- 모든 상태를 여러 훅에서 정의하고 최종적으로 useSession 훅이 이들을 모아 반환하면, 페이지 컴포넌트가 이 훅의 반환 값을 받아서 페이지 컴포넌트의 하위 컴포넌트에게
props로 전달해준다.
- 너무 많은 props를 전달하고 있으며 하위로 전달할 때 해당 props를 사용하지 않는 컴포넌트도 많다. 따라서 불필요한 리렌더링이 발생한다.
- 전달되는 상태가 많기 때문에 상태 추적 또한 복잡하다.
- 컴포넌트를 리팩토링을 시도하려면 지나치게 props로 전달 받는 상태에 의존하는 형태라 분리나 수정하는 것은 복잡한 문제이다.
- 기능 별로 나누어서 훅만 대충 만들어 놓았기 때문에, 분리하지 않아도 되는 것까지 분리되거나 혹은 기능 별 분리를 목표로 해두고 소켓 이벤트는 한 번에 처리하는 등 관심사 분리가 제대로 이루어지지 않았다.
- 여러 훅에서 각각 상태를 관리하고 있어서 상태의 출처나 변경 흐름 추적이 어렵다.
useSession이란 훅이 여러 훅을 한 번에 호출하는 역할을 해주면서 많은 책임을 지고 있어서 이를 분리해줄 필요가 있다.useScoketEvents훅에서는 모든 소켓 이벤트를 한 번에 처리해서 간편하지만, 한 곳에 로직이 집중되어 의존성이 복잡해지는 문제가 있다.
가장 먼저 props drilling을 해결해서 props를 사용하지 않는 컴포넌트가 이를 전달 받을 필요없이 구성하고, 해당 상태를 사용하는 컴포넌트만이 리렌더링될 수 있도록 할 필요가 있었다.
이를 위해 zustand 전역 상태 라이브러리로 대부분의 상태를 전역 상태로 관리할 수 있게 하기로 했다. zustand가 간단한 라이브러리라 사용하기가 편하다는 장점은 있었지만, 정확히 원리를 알고 사용하는게 좋을거 같아 zustand 동작 원리를 간단하게 공부하고 사용했다.
또한 훅들을 다시 정리하고, 훅 자체 내에서도 복잡한 로직을 분리할 계층을 도입하기로 했다. 특히나 미디어 관련 로직과 피어 커넥션 로직은 꽤나 복잡했기 때문에 복잡한 로직을 따로 뺄 수 있는 서비스 로직을 만들기로 결정했다.
해당 리팩토링 과정에서 수정된 코드가 굉장히 많았기 때문에 가장 많이 변경되고 복잡했던 미디어 관련 로직과 피어 커넥션 관련 로직을 리팩토링한 내용을 중심으로 작성했다.
전역 상태로 대부분 상태를 관리하도록 수정하기로 했다. 전역 상태로 변경하게 되면 얻는 이점은 다음과 같았다.
props drilling이 제거되기 때문에 상태를 전달해주는 복잡한 로직이 사라지고, 필요한 상태만을 각 컴포넌트에서 구독하고 상태 흐름 추적이 더 편해진다.미디어 관련된 상태와 피어와 관련된 상태, 그리고 채널 정보와 관련된 상태를 나누어서 관리하기로 했다.
useMediaStore: 미디어 관련 상태 관리
const useMediaStore = create<MediaState>((set) => ({
stream: null,
isVideoOn: true,
isMicOn: true,
videoLoading: false,
userVideoDevices: [],
userAudioDevices: [],
selectedVideoDeviceId: null,
selectedAudioDeviceId: null,
setStream: ...,
setIsVideoOn: ...,
setIsMicOn: ...,
setVideoLoading: ...,
setUserVideoDevices: ...,
setUserAudioDevices: ...,
setSelectedVideoDeviceId: ...,
setSelectedAudioDeviceId: ...,
}));
비디오/오디오 on/off 상태나 미디어 장치 관련 정보부터 이런 상태를 변경하는 API가 정의되어있다.
usePeerStore: 피어 관련 상태 관리
const usePeerStore = create<PeerState>((set) => ({
peers: [],
peerMediaStatus: {},
setPeers: ...,
setPeerMediaStatus: ...,
}));
피어 목록과 피어들의 비디오/오디오 상태를 저장하는 상태와 이를 변경하는 API가 정의되어있다.
useSessionStore: 채널 관련 상태 관리
const useSessionStore = create<SessionState>((set) => ({
nickname: null,
isHost: false,
roomId: "",
roomMetadata: {
title: "",
status: "PUBLIC",
participants: 0,
maxParticipants: 0,
createdAt: 0,
inProgress: false,
host: { socketId: "", createdAt: 0, nickname: "" },
category: "",
questionListId: 0,
questionListContents: [],
currentIndex: -1
},
participants: [],
ready: false,
setNickname: ...,
setIsHost: ...,
setRoomId: ...,
setRoomMetadata: ...,
setParticipants: ...,
setReady: ...,
}));
채널 자체가 가지는 정보인 채널 이름부터 호스트 정보, 스터디 채널에서 사용하는 질문 리스트 정보 상태와 해당 채널에서의 사용자의 닉네임, 호스트 여부 등의 상태와 이를 변경하는 API가 정의되어있다.
이렇게 전역 상태로 변경하고 나서 문제를 발견할 수 있었다.
📌 기존 훅 자체의 문제점
훅에서 데이터 채널과 피어커넥션을 useRef로 관리해주는 부분만 빼고 전역 상태로 관리하게 했다. 그래서 해당 상태를 usePeerConnection 훅에서 반환받아 사용했는데, 이를 다른 훅에서도 사용해야해서 전달해줘야해서 호출을 했고 하위 컴포넌트에서도 해당 값으로 UI를 보여주는 로직이 있어 2번을 호출하고 있었다.
그런데 props drilling을 없애고 각 컴포넌트가 필요한 상태만을 사용할 수 있게 설계하는게 목표였고, 위 문제는 훅 자체가 너무 많은 일을 하게 되어 생긴 일이었다. 따라서 데이터 채널, 피어커넥션 관리를 useRef로 훅에서 관리하는게 아닌 서비스 클래스를 만들어 이 인스턴스 안에서 상태 관리를 해주고 훅은 세션 관리(피어 연결과 관련된 로직)만 처리하고, 상태 관리 부분은 분리해서 해당 상태를 사용하는 곳에서는 해당 인스턴스를 불러오도록 설계를 바꾸기로 했다.
해당 리팩토링 부분은 아래에 더 자세히 작성하였다.
리팩토링 전의 usePeerConnection
const usePeerConnection = (socket: Socket) => {
const [peers, setPeers] = useState<PeerConnection[]>([]); // 연결 관리
const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({});
const dataChannels = useRef<{ [peerId: string]: RTCDataChannel }>({});
const [peerMediaStatus, setPeerMediaStatus] = useState<{
[peerId: string]: {
audio: boolean;
video: boolean;
};
}>({});
const pcConfig = { iceServers: [...] };
const createPeerConnection = async (...) => { 피어 커넥션 생성 };
const closePeerConnection = (peerSocketId: string) => { 피어커넥션 종료 };
return {
peers,
setPeers,
peerConnections,
createPeerConnection,
closePeerConnection,
dataChannels,
peerMediaStatus,
};
};
export default usePeerConnection;
리팩토링 전의 훅을 보면 다음과 같다.
리팩토링 후의 훅
const useWebRTCSession = (socket: Socket) => {
const setPeers = usePeerStore(state => state.setPeers);
...
const setParticipants = useSessionStore(state => state.setParticipants);
const webRTCManagerRef = useRef<WebRTCManager | null>(null);
useEffect(() => {
if (!socket || !nickname || !stream) return;
webRTCManagerRef.current = WebRTCManager.getInstance( ... );
const peerConnections = webRTCManagerRef.current.getPeerConnection();
const dataChannels = webRTCManagerRef.current.getDataChannels();
const handleGetOffer = async () => {
const pc = await webRTCManagerRef.current?.createPeerConnection( ... );
pc.setRemoteDescription(new RTCSessionDescription(data.sdp)),
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket?.emit(SIGNAL_EMIT_EVENT.ANSWER, { ... });
};
const handleGetAnswer = async () => {
await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
};
const handleGetCandidate = async () => {
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
};
const handleAllUsers = async (data: RoomJoinResponse) => {
const response = { /* 만들어진 방에 대한 데이터 */ };
setRoomMetadata(response);
setIsHost(response.host.socketId === socket.id);
setParticipants( ... );
for (const [socketId, userInfo] of Object.entries(data.connectionMap)) {
await webRTCManagerRef.current?.createPeerConnection( ... );
}
}
const handleUserExit = ({ socketId }: { socketId: string }) => {
webRTCManagerRef.current?.closePeerConnection(socketId);
};
socket.on(SIGNAL_LISTEN_EVENT.OFFER, handleGetOffer);
socket.on(SIGNAL_LISTEN_EVENT.ANSWER, handleGetAnswer);
...
return () => {
webRTCManagerRef.current?.cleanup();
webRTCManagerRef.current = null;
Object.values(dataChannels).forEach(channel => { channel.close(); });
socket.off(SIGNAL_LISTEN_EVENT.OFFER, handleGetOffer);
socket.off(SIGNAL_LISTEN_EVENT.ANSWER, handleGetAnswer);
...
};
}, [socket, nickname, stream]);
};
소켓 이벤트를 하나에서 관리하다 분리한 이유
소켓 이벤트는 스터디룸에서 스터디를 진행할 때 발생하는 이벤트도 있고 스터디룸 자체를 만들 때 커넥션을 생성하며 발생하는 이벤트 등 다양했다. 그러나 이걸 한 번에 등록하게 되면서 관련 핸들러 함수는 흩어져있었고, 소켓 이벤트와 관련 로직이 같은 위치에 있지 않다보니 파일을 왔다갔다 찾아다니는게 불편했다. 그래서 하나에서 관리하는게 아닌 관련 파일에서 관리하도록 변경했다.
WebRTC 매니저
class WebRTCManager {
private static instance: WebRTCManager | null = null;
private constructor(
socket: Socket,
setPeers: (update: (prev: PeerConnection[]) => PeerConnection[]) => void,
setPeerMediaStatus: (update: (prev: PeerMediaStatuses) => PeerMediaStatuses) => void,
setParticipants: (
participants: Participant[] | ((prev: Participant[]) => Participant[])
) => void,
) {
this.socket = socket;
this.pcConfig = { ... };
this.setPeers = setPeers;
this.setPeerMediaStatus = setPeerMediaStatus;
this.setParticipants = setParticipants;
}
public static getInstance( ... ) {
if (WebRTCManager.instance) return WebRTCManager.instance;
WebRTCManager.instance = new WebRTCManager( ... );
return WebRTCManager.instance;
}
public cleanup() {
Object.keys(this.peerConnections).forEach(peerId => {
this.closePeerConnection(peerId);
});
this.peerConnections = {};
this.dataChannels = {};
WebRTCManager.instance = null;
}
public getPeerConnection() return this.peerConnections;
public getDataChannels() return this.dataChannels;
private peerUpdated = ( ... ) => {
this.setPeers((prev) => {
const newPeers = prev.map(p =>
p.peerId === peerId ? { ...p, stream } : p
);
const isNewPeer = !prev.find(p => p.peerId === peerId);
if (isNewPeer) {
newPeers.push( ... );
this.setParticipants((prevParticipants) => { ... });
}
return newPeers;
});
}
private peerRemoved = (peerId: string) => {
this.setPeers(prev => { ... });
this.setParticipants(prev => { ... });
};
private mediaStatusChanged = () => { ... };
private handleConnectionFailure = () => {
this.peerRemoved(peerSocketId);
this.closePeerConnection(peerSocketId);
setTimeout(() => {
this.createPeerConnection( ... );
}, RETRY_CONNECTION_MS);
};
async createPeerConnection( ... ) {
if (this.peerConnections[peerSocketId]) {
const existingPc = this.peerConnections[peerSocketId];
if (existingPc.connectionState === "failed" ||
existingPc.connectionState === "disconnected") {
this.closePeerConnection(peerSocketId);
} else {
return existingPc;
}
}
const pc = new RTCPeerConnection(this.pcConfig);
this.peerConnections[peerSocketId] = pc;
if (stream) {
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
}
pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => {
if (!e.candidate) return;
this.socket.emit(SIGNAL_EMIT_EVENT.CANDIDATE, {
candidateReceiveID: peerSocketId,
candidate: e.candidate,
candidateSendID: this.socket.id,
});
};
pc.onsignalingstatechange = () => {
const candidates = this.pendingIceCandidates.get(peerSocketId) || [];
candidates.forEach(candidate => {
this.socket.emit(SIGNAL_EMIT_EVENT.CANDIDATE, {
candidateReceiveID: peerSocketId,
candidate,
candidateSendID: this.socket.id,
});
});
this.pendingIceCandidates.delete(peerSocketId);
};
const mediaDataChannel = pc.createDataChannel("media-status", {
ordered: true,
negotiated: true,
id: 0
});
mediaDataChannel.onopen = () => {
this.dataChannels[peerSocketId] = mediaDataChannel;
if (stream) {
const audioTracks = stream.getAudioTracks();
const audioEnabled = audioTracks.length > 0 && audioTracks[0].enabled;
const videoTracks = stream.getVideoTracks();
const videoEnabled = videoTracks.length > 0 && videoTracks[0].label !== "blackTrack";
mediaDataChannel.send(JSON.stringify({ type: "audio", status: audioEnabled }));
mediaDataChannel.send(JSON.stringify({ type: "video", status: videoEnabled }));
}
};
mediaDataChannel.onmessage = (e) => {
const data = JSON.parse(e.data);
this.mediaStatusChanged(peerSocketId, data.type, data.status);
};
mediaDataChannel.onclose = () => {
delete this.dataChannels[peerSocketId];
};
pc.ontrack = (e) => {
this.peerUpdated(peerSocketId, peerNickname, e.streams[0], localUser.isHost);
const audioTracks = e.streams[0].getAudioTracks();
const videoTracks = e.streams[0].getVideoTracks();
this.mediaStatusChanged(peerSocketId, 'audio', audioTracks.length > 0 && audioTracks[0].enabled);
this.mediaStatusChanged(peerSocketId, 'video', videoTracks.length > 0 && videoTracks[0].label !== "blackTrack");
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "disconnected" || pc.connectionState === "failed") {
this.handleConnectionFailure(peerSocketId, peerNickname, stream, isOffer, localUser);
}
};
if (isOffer) {
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (this.socket && pc.localDescription) {
this.socket.emit(SIGNAL_EMIT_EVENT.OFFER, {
offerReceiveID: peerSocketId,
sdp: pc.localDescription,
offerSendID: this.socket.id,
offerSendNickname: localUser.nickname,
});
}
} catch (error) {
console.error(error);
}
}
return pc;
};
closePeerConnection(peerSocketId: string) {
if (this.peerConnections[peerSocketId]) {
const pc = this.peerConnections[peerSocketId];
...
delete this.peerConnections[peerSocketId];
delete this.dataChannels[peerSocketId];
pc.close();
this.peerRemoved(peerSocketId);
}
};
}
코드가 길어서 일부분은 생략했다.
싱글톤으로 구현했는데, 현재 해당 매니저에서 연결 상태나 데이터 채널과 같은 상태를 관리해주고 있기 때문에 이를 사용해야하는 컴포넌트에서 언제든지 불러와 사용할 수 있게 구현하기 위해서 싱글톤으로 만들게 되었다.이 훅이 대표적인 예시라 작성했지만, 미디어 관리를 해주는 훅에서도 서비스 레이어를 넣어서 복잡한 로직을 서비스 계층에서 처리하도록 했다.
결과적으로 훅에서 처리하던 피어 상태와 같은 상태들을 전역 상태로 관리하게 되었고, 피어 연결 생성 및 관리 로직은 매니저가 관리한다. 훅 자체는 소켓 이벤트 등록/해제와 WebRTC 매니저 인스턴스 생명주기 관리만 담당하면서 책임을 명확하게 할 수 있게 했다.
MediaManager
class MediaManager {
async getUserDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
return {
audioDevices: devices.filter(device => device.kind === "audioinput"),
videoDevices: devices.filter(device => device.kind === "videoinput"),
hasPermission: devices.some(device => device.deviceId !== "")
};
}
stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop());
}
combineTracks(videoStream: MediaStream | null, audioStream: MediaStream | null) {
const tracks = [
...(videoStream?.getVideoTracks() || [createDummyStream()]),
...(audioStream?.getAudioTracks() || [])
]
return new MediaStream(tracks);
}
async getMediaStream(
mediaType: MediaStreamType,
deviceId: string | null,
) {
try {
const constraints = {
video: mediaType === "video" ? (deviceId ? { deviceId } : true) : false,
audio: mediaType === "audio" ? (deviceId ? { deviceId } : true) : false
};
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (error) {
console.warn(`${mediaType} 스트림을 가져오는데 실패했습니다:`, error);
return null;
}
};
async replaceVideoTrack(peerConnection: RTCPeerConnection, newTrack: MediaStreamTrack) {
const sender = peerConnection
.getSenders()
.find((s) => s.track?.kind === "video");
if (sender) {
return await sender.replaceTrack(newTrack);
}
}
removeVideoTracks(stream: MediaStream) {
const videoTracks = stream.getVideoTracks();
videoTracks.forEach((track) => {
track.stop();
stream.removeTrack(track);
});
}
toggleAudioTrack(audioTrack: MediaStreamTrack, enable: boolean) {
audioTrack.enabled = enable;
}
}
훅 자체가 명확히 관련된 일을 하도록 모아보면서 훅 자체의 코드 라인 수가 많은 게 아쉬웠다. 물론 기능이 많고 복잡하다보면 코드 라인 수는 상관 없을지도 모른다. 그렇지만 명확히 훅에서 어떤 일을 하는지 잘 읽히지 않았기 때문에 걱정이 있었다.
서비스 계층을 도입해서 복잡한 로직을 캡슐화하며, 가독성이 어느정도 향상되었다는 점이 가장 만족스러운 점이다.(더 개선될 여지는 있다.) 사실 중간에 전역 상태로 바꾸면서 props drilling을 1차적으로 제거해서 상태 관리를 잘 하면 괜찮을거라 생각하고 신경썼는데 훅의 너무 많은 책임 및 복잡함으로 인해 소켓 이벤트가 여러번 등록되며 화상회의가 동작하지 않았던 문제를 겪으며 구조를 생각하지 않고 훅을 분리했던 것에 대해 반성했다.
현재는 복잡한 로직을 서비스에 위임한다는 느낌이라 진정한 계층 분리가 이루어졌는가?에 대한 물음에 있어서는 살짝 아쉬움이 있다. 더 명확히 계층을 더 분리할 수도 있을거 같다. 그러나 너무 많은 코드 양을 리팩토링하다보니 살짝 지쳐서 일단 여기까지 해두었다.
위 리팩토링 과정에서 비즈니스 로직을 서비스 계층을 만들어 분리해서, 복잡한 로직을 서비스 계층에서 처리할 수 있게 구현했다. 하지만 이렇게 분리했음에도 기능 자체가 복잡해서 복잡한 부분을 서비스에서 처리하도록 임시로 해둔 느낌이 강했다.
상태 변경이 일어나는 부분이나 이벤트를 처리하거나 외부와 통신하는 등 각 역할 별로 계층을 명확히 나누는 것을 시도해보는게 좋을거 같다.
특히나 UI 부분과 비즈니스 로직을 제대로 분리해서 UI 코드는 UI에만 집중할 수 있게 구성해두는게 필요해보인다.
각 커스텀 훅이 어떤 역할을 하는지 역할을 명확히 할 필요가 있다고 느꼈다. 특히나 커스텀 훅에서 어떤 값을 반환하는데 여러 군데서 호출해서 사용해서 useEffect가 여러번 실행되어 의도치 않은 코드 실행이 발생할 수 있다는 걸 이번 리팩토링을 통해 알게되었다.
또한 커스텀 훅 내에서도 여러가지 일을 하고 있다면 이것도 계층을 나누어 수정해봐야겠다.
위 2가지 일을 하기 전 상위 컴포넌트의 구조를 다시 정리한 뒤 해야할 거 같다. 합성 컴포넌트 개념을 도입해서 코드를 명확히 구조화를 시도해봐야겠다.