background
aurora10

전체화면 오류

2025년 7월 30일

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

image.png

image.png

다음 이미지가 사용자가 음성 채팅을 이용할 때의 흐름도 인데

useVoiceSlice

typescript
import { 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

typescript
import { 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

typescript
import { 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

typescript
import { 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

typescript
import { 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

typescript
import { 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

image.png

(전체화면 후)

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

image.png

image.png

공식문서를 확인해보니 그건 아니라고 한다…

그래서 삽질을 1시간 넘게 하던 와중

image.png

image.png

클로드에게 의미심장한 답변을 얻을 수 있었는데

그래서 혹시나 하는 생각으로 사파리로 실행을 해보았는데

image.png

image.png

너무 잘된다..

(크롬의 브라우저 정책에 의해 localhost 환경에서 fullscreen 을 막아놨다고 하는데 그럼 지금까지 했던 사람들은 뭐지?)

이렇게 전체화면과 관련된 이슈가 해결이 되었고 지금은 기존 훅을 더 잘게 쪼개는 작업을 진행중이다. 아마 다음에는 카메라 연결 부분 할 것 같은데 과거 카메라 연결은 해 본 경험이 있어서 금방하지 않을까?

태그

#aurora#Next.js