전체화면 오류
오로라 음성 채널 UI 작업 도중 전체화면에 대한 기능을 구현을 진행하였는데 먼저, 음성 채널에서의 상태 값은 계속 유지 되어야 할 것 같아서 redux 를 통한 상태 관리를 먼저 해주었다.

image.png
다음 이미지가 사용자가 음성 채팅을 이용할 때의 흐름도 인데
useVoiceSlice
typescriptimport { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Message } from "../types";
import {
VoiceChannelState,
VoiceParticipant,
} from "../types/voiceChannelTypes";
// 초기 상태
const initialState: VoiceChannelState = {
channels: {},
currentUser: {
userId: "current-user", // 실제로는 auth에서 가져와야 함
isSpeakerOn: false,
isScreenShareOpen: false,
},
};
// Voice Channel 슬라이스 생성
const voiceChannelSlice = createSlice({
name: "voiceChannel",
initialState,
reducers: {
// 채널 초기화
initializeChannel: (
state,
action: PayloadAction<{ channelId: string; messages: Message[] }>
) => {
const { channelId, messages } = action.payload;
if (!state.channels[channelId]) {
state.channels[channelId] = {
participants: {},
isScreenShareActive: false,
activeScreenSharer: null,
isSettingsOpen: false,
isFullScreen: false,
messages,
};
}
},
// 참여자 추가
addParticipant: (
state,
action: PayloadAction<{
channelId: string;
participant: VoiceParticipant;
}>
) => {
const { channelId, participant } = action.payload;
if (state.channels[channelId]) {
state.channels[channelId].participants[participant.userId] =
participant;
}
},
// 참여자 제거
removeParticipant: (
state,
action: PayloadAction<{ channelId: string; userId: string }>
) => {
const { channelId, userId } = action.payload;
if (state.channels[channelId]) {
delete state.channels[channelId].participants[userId];
}
},
// 마이크 상태 토글
toggleParticipantMic: (
state,
action: PayloadAction<{ channelId: string; userId: string }>
) => {
const { channelId, userId } = action.payload;
const participant = state.channels[channelId]?.participants[userId];
if (participant) {
participant.isMicOn = !participant.isMicOn;
}
},
// 비디오 상태 토글
toggleParticipantVideo: (
state,
action: PayloadAction<{ channelId: string; userId: string }>
) => {
const { channelId, userId } = action.payload;
const participant = state.channels[channelId]?.participants[userId];
if (participant) {
participant.isVideoOn = !participant.isVideoOn;
}
},
// 오디오 상태 토글
toggleParticipantAudio: (
state,
action: PayloadAction<{ channelId: string; userId: string }>
) => {
const { channelId, userId } = action.payload;
const participant = state.channels[channelId]?.participants[userId];
if (participant) {
participant.isAudioOn = !participant.isAudioOn;
}
},
// 스피커 상태 토글 (로컬)
toggleSpeaker: (state) => {
state.currentUser.isSpeakerOn = !state.currentUser.isSpeakerOn;
},
// 화면 공유 상태 토글
toggleScreenShare: (
state,
action: PayloadAction<{ channelId: string; userId: string }>
) => {
const { channelId, userId } = action.payload;
const channel = state.channels[channelId];
if (channel) {
if (channel.activeScreenSharer === userId) {
// 현재 공유자가 다시 토글하면 중지
channel.isScreenShareActive = false;
channel.activeScreenSharer = null;
} else {
// 새로운 사용자가 화면 공유 시작
channel.isScreenShareActive = true;
channel.activeScreenSharer = userId;
}
}
},
// 화면 공유 창 토글 (로컬)
toggleScreenShareOpen: (state) => {
state.currentUser.isScreenShareOpen =
!state.currentUser.isScreenShareOpen;
},
// 전체 화면 토글
toggleFullScreen: (state, action: PayloadAction<{ channelId: string }>) => {
const { channelId } = action.payload;
if (state.channels[channelId]) {
state.channels[channelId].isFullScreen =
!state.channels[channelId].isFullScreen;
}
},
// 설정 창 토글
toggleSettings: (state, action: PayloadAction<{ channelId: string }>) => {
const { channelId } = action.payload;
if (state.channels[channelId]) {
state.channels[channelId].isSettingsOpen =
!state.channels[channelId].isSettingsOpen;
}
},
// 메시지 추가
addMessage: (
state,
action: PayloadAction<{ channelId: string; message: Message }>
) => {
const { channelId, message } = action.payload;
if (state.channels[channelId]) {
state.channels[channelId].messages.push(message);
}
},
// 발언 상태 설정
setSpeaking: (
state,
action: PayloadAction<{
channelId: string;
userId: string;
isSpeaking: boolean;
}>
) => {
const { channelId, userId, isSpeaking } = action.payload;
const participant = state.channels[channelId]?.participants[userId];
if (participant) {
participant.isSpeaking = isSpeaking;
}
},
},
});
// 액션 내보내기
export const {
initializeChannel,
addParticipant,
removeParticipant,
toggleParticipantMic,
toggleParticipantVideo,
toggleParticipantAudio,
toggleSpeaker,
toggleScreenShare,
toggleScreenShareOpen,
toggleFullScreen,
toggleSettings,
addMessage,
setSpeaking,
} = voiceChannelSlice.actions;
// 리듀서 내보내기
export default voiceChannelSlice.reducer;
다음과 같이 slice를 작성하고
useConnectRedux
typescriptimport { useDispatch, useSelector } from "react-redux";
import { useCallback } from "react";
import type { RootState, AppDispatch } from "../../lib/store";
import { Message } from "../types";
import { VoiceParticipant } from "../types/voiceChannelTypes";
import {
initializeChannel,
addParticipant,
removeParticipant,
toggleParticipantMic,
toggleParticipantVideo,
toggleParticipantAudio,
toggleSpeaker,
toggleScreenShare,
toggleScreenShareOpen,
toggleFullScreen,
toggleSettings,
addMessage,
setSpeaking,
} from "../store/voiceChannelSlice";
// 커스텀 훅
export const useConnectRedux = (channelId: string) => {
const dispatch = useDispatch<AppDispatch>();
const voiceChannelState = useSelector(
(state: RootState) => state.voiceChannel
);
const currentUserId = voiceChannelState.currentUser.userId;
// 현재 채널 상태
const channelState = voiceChannelState.channels[channelId];
const currentParticipant = channelState?.participants[currentUserId];
const actions = {
// 채널 초기화
initializeChannel: useCallback(
(messages: Message[]) => {
dispatch(initializeChannel({ channelId, messages }));
},
[dispatch, channelId]
),
// 참여자 관리
joinChannel: useCallback(
(participant: VoiceParticipant) => {
dispatch(addParticipant({ channelId, participant }));
},
[dispatch, channelId]
),
leaveChannel: useCallback(
(userId: string) => {
dispatch(removeParticipant({ channelId, userId }));
},
[dispatch, channelId]
),
// 현재 사용자 상태 토글
toggleMyMic: useCallback(() => {
dispatch(toggleParticipantMic({ channelId, userId: currentUserId }));
}, [dispatch, channelId, currentUserId]),
toggleMyVideo: useCallback(() => {
dispatch(toggleParticipantVideo({ channelId, userId: currentUserId }));
}, [dispatch, channelId, currentUserId]),
toggleMyAudio: useCallback(() => {
dispatch(toggleParticipantAudio({ channelId, userId: currentUserId }));
}, [dispatch, channelId, currentUserId]),
toggleSpeaker: useCallback(() => {
dispatch(toggleSpeaker());
}, [dispatch]),
toggleMyScreenShare: useCallback(() => {
dispatch(toggleScreenShare({ channelId, userId: currentUserId }));
}, [dispatch, channelId, currentUserId]),
toggleScreenShareOpen: useCallback(() => {
dispatch(toggleScreenShareOpen());
}, [dispatch]),
// 채널 설정
toggleFullScreen: useCallback(() => {
dispatch(toggleFullScreen({ channelId }));
}, [dispatch, channelId]),
toggleSettings: useCallback(() => {
dispatch(toggleSettings({ channelId }));
}, [dispatch, channelId]),
// 메시지
sendMessage: useCallback(
(message: Message) => {
dispatch(addMessage({ channelId, message }));
},
[dispatch, channelId]
),
// 발언 상태
setSpeaking: useCallback(
(userId: string, isSpeaking: boolean) => {
dispatch(setSpeaking({ channelId, userId, isSpeaking }));
},
[dispatch, channelId]
),
};
return {
// 상태
participants: channelState?.participants || {},
messages: channelState?.messages || [],
isScreenShareActive: channelState?.isScreenShareActive || false,
activeScreenSharer: channelState?.activeScreenSharer,
isSettingsOpen: channelState?.isSettingsOpen || false,
isFullScreen: channelState?.isFullScreen || false,
isSpeakerOn: voiceChannelState.currentUser.isSpeakerOn,
isScreenShareOpen: voiceChannelState.currentUser.isScreenShareOpen,
// 현재 사용자 상태
isMicOn: currentParticipant?.isMicOn || false,
isVideoOn: currentParticipant?.isVideoOn || false,
isAudioOn: currentParticipant?.isAudioOn || false,
isSpeaking: currentParticipant?.isSpeaking || false,
// 액션들
...actions,
// 유틸리티
isScreenSharing: channelState?.activeScreenSharer === currentUserId,
participantCount: Object.keys(channelState?.participants || {}).length,
};
};
redux와 연결해주는 hook을 작성한 뒤
useVoiceChannelPage
typescriptimport { useParams } from "next/navigation";
import { channelNames } from "../types/data";
import { useEffect } from "react";
import { defaultMessages } from "../types/channelData";
import { useConnectRedux } from "./useConnectRedux";
import { VoiceParticipant } from "../types/voiceChannelTypes";
export const useVoiceChannelPage = () => {
const params = useParams();
const serverId = params.server_id as string;
const projectId = params.project_id as string;
const channelId = params.channel_id as string;
// Redux 상태 및 액션 가져오기
const {
// 상태
participants,
messages,
isScreenShareActive,
activeScreenSharer,
isSettingsOpen,
isFullScreen,
isSpeakerOn,
isScreenShareOpen,
isMicOn,
isVideoOn,
isAudioOn,
isSpeaking,
isScreenSharing,
participantCount,
// 액션
initializeChannel,
joinChannel,
leaveChannel,
toggleMyMic,
toggleMyVideo,
toggleMyAudio,
toggleSpeaker,
toggleMyScreenShare,
toggleScreenShareOpen,
toggleFullScreen,
toggleSettings,
sendMessage,
setSpeaking,
} = useConnectRedux(channelId);
// 채널 초기화
useEffect(() => {
initializeChannel(defaultMessages);
// 현재 사용자를 참여자로 추가
const currentUser: VoiceParticipant = {
userId: "current-user", // 실제로는 auth에서 가져와야 함
username: "심근원", // 실제로는 auth에서 가져와야 함
isMicOn: true,
isVideoOn: false,
isAudioOn: true,
isSpeaking: false,
};
// 테스트용 추가 참여자들
const testParticipants: VoiceParticipant[] = [
{
userId: "user-2",
username: "김병년",
isMicOn: false,
isVideoOn: true,
isAudioOn: true,
isSpeaking: false,
},
{
userId: "user-3",
username: "박준혁",
isMicOn: true,
isVideoOn: false,
isAudioOn: true,
isSpeaking: true,
},
{
userId: "user-4",
username: "이수진",
isMicOn: true,
isVideoOn: true,
isAudioOn: true,
isSpeaking: false,
},
];
joinChannel(currentUser);
testParticipants.forEach((participant) => joinChannel(participant));
// 컴포넌트 언마운트 시 채널에서 나가기
return () => {
leaveChannel("current-user");
testParticipants.forEach((participant) =>
leaveChannel(participant.userId)
);
};
}, [channelId, initializeChannel, joinChannel, leaveChannel]); // useCallback으로 memoized된 함수들은 안전
// 채널 이름 가져오기
const getChannelName = (id: string) => {
return channelNames[id] || id;
};
return {
// URL 파라미터
serverId,
projectId,
channelId,
// 유틸리티 함수
getChannelName,
// 상태
isMicOn,
isSpeakerOn,
isSettingsOpen,
isFullScreen,
isVideoOn,
isAudioOn,
isScreenShareOpen,
isScreenShareActive,
isSpeaking,
isScreenSharing,
// 참여자 및 채널 상태
participants,
participantCount,
activeScreenSharer,
messages,
// 액션 (기존 API 호환성 유지)
toggleMic: toggleMyMic,
toggleSpeaker,
toggleScreenShare: toggleMyScreenShare,
toggleScreenShareOpen,
toggleFullScreen,
toggleVideo: toggleMyVideo,
toggleAudio: toggleMyAudio,
toggleSettings,
// 새로운 Redux 전용 액션들
sendMessage,
setSpeaking,
joinChannel,
leaveChannel,
};
};
다음과 같이 상태 값에 따른 UI 설계 훅을 작성하였다. 근데 하나에 훅에 너무 많은 로직이 들어가 있는 것 같아서 SRP 에 위반 된다고 생각되어 훅을 잘게 쪼게기 시작했다. 그래서 처음으로 작성 한 훅은
useGridLayout
typescriptimport { useMemo } from "react";
export const useVoiceGrid = (
channelId: string,
participantCount: number,
isMicOn: boolean,
isVideoOn: boolean,
isScreenShareActive: boolean,
participants: { [key: string]: any }
) => {
// 참여자 수에 따른 그리드 레이아웃 계산
const getGridLayout = useMemo(() => {
if (participantCount === 1) return "grid-cols-1";
if (participantCount === 2) return "grid-cols-2";
if (participantCount <= 4) return "grid-cols-2";
if (participantCount <= 6) return "grid-cols-3";
if (participantCount <= 9) return "grid-cols-3";
return "grid-cols-4";
}, [participantCount]);
const getGridRows = useMemo(() => {
if (participantCount <= 2) return "grid-rows-1";
if (participantCount <= 6) return "grid-rows-2";
if (participantCount <= 12) return "grid-rows-3";
return "grid-rows-4";
}, [participantCount]);
// 디버깅 정보
const debugInfo = useMemo(
() => ({
channelId,
participantCount,
isMicOn,
isVideoOn,
isScreenShareActive,
participants: Object.keys(participants),
gridLayout: getGridLayout,
gridRows: getGridRows,
}),
[
channelId,
participantCount,
isMicOn,
isVideoOn,
isScreenShareActive,
participants,
getGridLayout,
getGridRows,
]
);
return {
gridLayout: getGridLayout,
gridRows: getGridRows,
debugInfo,
};
};
단순하게 현재 사용자의 수에 따라 그리드 레이아웃을 적용하는 훅이고 useMemo를 사용하여 불필요한 렌더링을 방지하였다.
useFullScreen
typescriptimport { useState, useEffect, useCallback } from "react";
interface UseFullscreenOptions {
onToggle?: () => void;
element?: HTMLElement;
}
export const useFullscreen = (options: UseFullscreenOptions = {}) => {
const { onToggle, element } = options;
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const targetElement =
element ||
(typeof document !== "undefined" ? document.documentElement : null);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (typeof document === "undefined" || !isMounted) {
return;
}
const handleFullscreenChange = () => {
const currentIsFullscreen = !!document.fullscreenElement;
setIsFullscreen(currentIsFullscreen);
if (onToggle) {
onToggle();
}
};
const events = [
"fullscreenchange",
"webkitfullscreenchange",
"mozfullscreenchange",
"MSFullscreenChange",
];
events.forEach((event) => {
document.addEventListener(event, handleFullscreenChange);
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && document.fullscreenElement) {
handleFullscreenChange();
}
};
document.addEventListener("keydown", handleKeyDown);
setIsFullscreen(!!document.fullscreenElement);
return () => {
events.forEach((event) => {
document.removeEventListener(event, handleFullscreenChange);
});
document.removeEventListener("keydown", handleKeyDown);
};
}, [onToggle, isMounted]);
const enterFullscreen = useCallback(async () => {
if (!targetElement) {
if (onToggle) onToggle();
return;
}
try {
if (targetElement.requestFullscreen) {
await targetElement.requestFullscreen();
} else if ((targetElement as any).webkitRequestFullscreen) {
await (targetElement as any).webkitRequestFullscreen();
} else if ((targetElement as any).mozRequestFullScreen) {
await (targetElement as any).mozRequestFullScreen();
} else if ((targetElement as any).msRequestFullscreen) {
await (targetElement as any).msRequestFullscreen();
} else {
if (onToggle) onToggle();
}
} catch (error) {
console.error("Enter fullscreen failed:", error);
if (onToggle) onToggle();
}
}, [targetElement, onToggle]);
const exitFullscreen = useCallback(async () => {
if (typeof document === "undefined") {
return;
}
try {
if (document.exitFullscreen) {
await document.exitFullscreen();
} else if ((document as any).webkitExitFullscreen) {
await (document as any).webkitExitFullscreen();
} else if ((document as any).mozCancelFullScreen) {
await (document as any).mozCancelFullScreen();
} else if ((document as any).msExitFullscreen) {
await (document as any).msExitFullscreen();
}
} catch (error) {
console.error("Exit fullscreen failed:", error);
}
}, []);
const toggleFullscreen = useCallback(async () => {
if (isFullscreen) {
await exitFullscreen();
} else {
await enterFullscreen();
}
}, [isFullscreen, enterFullscreen, exitFullscreen]);
const isSupported = useCallback(() => {
if (typeof document === "undefined") {
return false;
}
return !!(
document.fullscreenEnabled ||
(document as any).webkitFullscreenEnabled ||
(document as any).mozFullScreenEnabled ||
(document as any).msFullscreenEnabled
);
}, []);
return {
isFullscreen: isMounted ? isFullscreen : false,
enterFullscreen,
exitFullscreen,
toggleFullscreen,
isSupported: isMounted ? isSupported() : false,
};
};
브라우저의 내장 기능인 fullScreenAPI를 활용하여 전체 화면에 대한 훅을 작성하였다. 그 다음 컴포넌트에 해당 훅을 사용하도록 정의했고
FullScreenButton
typescriptimport { useFullscreen } from "../../../../../hooks/useFullscreen";
interface FullscreenButtonProps {
onToggleFullscreen: () => void;
isFullScreen: boolean;
}
export const FullscreenButton = ({
onToggleFullscreen,
isFullScreen,
}: FullscreenButtonProps) => {
// useFullscreen hook 사용
const { isFullscreen, toggleFullscreen, isSupported } = useFullscreen();
// 맥북 크롬 감지
const isMacChrome = () => {
return (
navigator.platform.includes("Mac") &&
navigator.userAgent.includes("Chrome")
);
};
// 전체화면 토글 핸들러
const handleClick = async () => {
try {
if (isSupported) {
// hook의 toggleFullscreen 사용
await toggleFullscreen();
}
// Redux 상태도 동기화
onToggleFullscreen();
} catch (error) {
// 맥북 크롬에서 차단되는 경우 안내 메시지
if (isMacChrome()) {
alert(
'🔒 크롬에서 전체화면이 차단되었습니다.\n\n해결 방법:\n1. 주소창 좌측 🔒 아이콘 클릭\n2. "팝업 및 리디렉션" → 허용\n3. 페이지 새로고침\n\n또는 Safari 브라우저를 사용해보세요!'
);
}
// 실패해도 Redux 상태는 토글 (UI 피드백용)
onToggleFullscreen();
}
};
// 실제 브라우저 fullscreen 상태 우선, 없으면 Redux 상태 사용
const displayState = isSupported ? isFullscreen : isFullScreen;
// 맥북 크롬 사용자를 위한 툴팁 메시지
const getTooltip = () => {
if (isMacChrome() && !isSupported) {
return displayState
? "전체화면 나가기 (ESC)"
: "전체화면 진입 (크롬에서 차단될 수 있음 - Safari 권장)";
}
return displayState ? "전체화면 나가기 (ESC)" : "전체화면 진입";
};
return (
<button
onClick={handleClick}
className={`absolute bottom-6 right-6 w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 ${
displayState
? "bg-blue-600 hover:bg-blue-700 scale-110"
: "bg-gray-800 hover:bg-gray-700"
} ${isMacChrome() && !isSupported ? "ring-1 ring-yellow-400" : ""}`}
aria-label={getTooltip()}
title={getTooltip()}
>
{displayState ? (
<svg
className="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8 4a1 1 0 011-1h2a1 1 0 110 2H9.414l2.293 2.293a1 1 0 11-1.414 1.414L8 6.414V8a1 1 0 11-2 0V4a1 1 0 011-1zm4 12a1 1 0 01-1 1H9a1 1 0 110-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L12 13.586V12a1 1 0 112 0v4a1 1 0 01-1 1z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
)}
</button>
);
};
이렇게 먼저 전체 화면에 대한 로직부터 작성을 해보았고 테스트를 진행해 보았다.
근데…

image.png
(전체화면 전)

image.png
(전체화면 후)
이미지와 같이 전체 화면을 해도 크기가 확장되긴 커녕 더 작아지는 걸 확인할 수 있었는데 처음에는 전체 화면이라는 것 자체가 모든 페이지에 적용되야 하니까 컴포넌트 단에서 정의하는 것이 아닌가? 하는 의문이 발생했는데

image.png
공식문서를 확인해보니 그건 아니라고 한다…
그래서 삽질을 1시간 넘게 하던 와중

image.png
클로드에게 의미심장한 답변을 얻을 수 있었는데
그래서 혹시나 하는 생각으로 사파리로 실행을 해보았는데

image.png
너무 잘된다..
(크롬의 브라우저 정책에 의해 localhost 환경에서 fullscreen 을 막아놨다고 하는데 그럼 지금까지 했던 사람들은 뭐지?)
이렇게 전체화면과 관련된 이슈가 해결이 되었고 지금은 기존 훅을 더 잘게 쪼개는 작업을 진행중이다. 아마 다음에는 카메라 연결 부분 할 것 같은데 과거 카메라 연결은 해 본 경험이 있어서 금방하지 않을까?
