background
aurora5분

카메라 연결

2025년 8월 1일

저번에 음성채널 관련해서 전체화면 로직을 구현하였는데 정상 작동되는 걸 확인하였고 카메라 연결 기능으로 넘어갔다. 카메라 연결 하는 건 이전 프로젝트에서 진행해봤기 대문에 크게 어랴움 없이 구현을 할 수 있을 것이라 생각했다.

먼저 카메라 연결을 위한 훅을 설정해주었다.

useCamera

typescript
import { useState } from "react";

export const useCamera = () => {
  const [cameraStream, setCameraStream] = useState<MediaStream | null>(null);
  const [cameraError, setCameraError] = useState<string | null>(null);

  const startCamera = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true,
      });
      setCameraStream(stream);
      setCameraError(null);
    } catch (error) {
      setCameraError(error instanceof Error ? error.message : "Unknown error");
      setCameraStream(null);
    }
  };

  const stopCamera = () => {
    if (cameraStream) {
      cameraStream.getTracks().forEach((track) => track.stop());
      setCameraStream(null);
    }
  };

  const toggleCamera = () => {
    if (cameraStream) {
      stopCamera();
    } else {
      startCamera();
    }
  };

  return {
    cameraStream,
    setCameraStream,
    cameraError,
    setCameraError,
    startCamera,
    stopCamera,
    toggleCamera,
  };
};

간단하게 카메라 stream의 상태를 정의 해주고 WebRTC API의 navigator.mediaDevices.getUserMedia 함수를 이용하여 비디오 접근 권한을 가져왔고 카메라 stream의 상태를 업데이트 해주었다. 그 후 토글을 다시 눌렀을 때 카메라 stream 을 정리하기 위해 stomCamera 함수를 정의하고 getTracks 함수를 이용하였다. 이제 toggleCamera 함수를 활용해 사용자가 버튼을 누를 때마다 카메라가 시작되고 꺼지는 것을 정의하였다.


그 후 해당 훅과 redux 상태 값과 연결하기 위해 useMediaConrol 훅을 생성해주었는데 이 훅의 경우 추후에 오디오, 마이크의 상태값 등 미디어와 관련된 상태를 제어 할 수 있도록 구조를 작성하였다.

useMediaControl

typescript
import { useCallback } from "react";
import { useCamera } from "./useCamera";
import { useLoadRedux } from "./useLoadRedux";

export const useMediaControl = () => {
  const { cameraStream, startCamera, stopCamera } = useCamera();
  const { isVideoOn, toggleMyVideo } = useLoadRedux();

  const handleToggleVideo = useCallback(() => {
    // 1. Redux 상태 먼저 업데이트
    toggleMyVideo();

    // 2. 현재 상태에 따라 카메라 제어
    if (isVideoOn) {
      stopCamera(); // 현재 켜져있으면 끄기
    } else {
      startCamera(); // 현재 꺼져있으면 켜기
    }
  }, [isVideoOn, toggleMyVideo, startCamera, stopCamera, cameraStream]);

  return {
    cameraStream,
    startCamera,
    stopCamera,
    handleToggleVideo,
  };
};

이렇게 해당 훅들을 이용하여 음성 채널의 최상위 컴포넌트인 page 에 훅을 초기화 해주고 props를 통해 카메라 stream 상태를 넘겨주었다.

page

typescript
"use client";

import { useVoiceChannelPage } from "../../../../../hooks/useVoiceChannelPage";
import { useVoiceGrid } from "../../../../../hooks/useVoiceGrid";
import { useMediaControl } from "../../../../../hooks/useMediaControl";
import dynamic from "next/dynamic";
import {
  VoiceHeader,
  ScreenShareView,
  VoiceGrid,
  VoiceControlBar,
} from "../components";

// FullscreenButton을 클라이언트에서만 로드 (hydration 에러 방지)
const FullscreenButton = dynamic(
  () =>
    import("../components").then((mod) => ({ default: mod.FullscreenButton })),
  {
    ssr: false,
    loading: () => null, // 로딩 중에는 아무것도 보여주지 않음
  }
);

const VoiceChannelPage = () => {
  const {
    channelId,
    getChannelName,
    currentUserId,
    isMicOn,
    isFullScreen,
    isVideoOn,
    isScreenShareActive,
    isScreenSharing,
    participants,
    participantCount,
    toggleMic,
    toggleFullScreen,
    toggleScreenShare,
  } = useVoiceChannelPage();

  // 미디어 제어 (카메라 포함)
  const { cameraStream, handleToggleVideo } = useMediaControl();

  // 참여자 수에 따른 그리드 레이아웃 계산
  const { gridLayout, gridRows } = useVoiceGrid(
    channelId,
    participantCount,
    isMicOn,
    isVideoOn,
    isScreenShareActive,
    participants
  );

  return (
    <div className="h-full bg-aurora-voice text-white flex flex-col relative">
      {/* 상단 헤더 */}
      <VoiceHeader channelName={getChannelName(channelId)} />

      {/* 메인 비디오 영역 */}
      <div className="flex-1 p-8 flex items-center justify-center">
        {isScreenShareActive ? (
          /* 화면 공유 모드 */
          <ScreenShareView
            participants={participants}
            currentUserId={currentUserId}
            cameraStream={cameraStream}
          />
        ) : (
          /* 일반 화상회의 모드 */
          <VoiceGrid
            participants={participants}
            gridLayout={gridLayout}
            gridRows={gridRows}
            currentUserId={currentUserId}
            cameraStream={cameraStream}
          />
        )}
      </div>

      {/* 하단 컨트롤 바 */}
      <VoiceControlBar
        isMicOn={isMicOn}
        isVideoOn={isVideoOn}
        isScreenSharing={isScreenSharing}
        onToggleMic={toggleMic}
        onToggleVideo={handleToggleVideo}
        onToggleScreenShare={toggleScreenShare}
      />

      {/* 전체화면 버튼 - 클라이언트에서만 렌더링 */}
      <FullscreenButton
        onToggleFullscreen={toggleFullScreen}
        isFullScreen={isFullScreen}
      />
    </div>
  );
};

export default VoiceChannelPage;

이제 카메라를 끄고 킬 수 있도록, 또한 카메라가 켜져 있을 경우 화면이 나오도록 컴포넌트를 수정하였다.

VoiceControlBar

typescript
interface VoiceControlBarProps {
  isMicOn: boolean;
  isVideoOn: boolean;
  isScreenSharing: boolean;
  onToggleMic: () => void;
  onToggleVideo: () => void;
  onToggleScreenShare: () => void;
  onEndCall?: () => void;
}

export const VoiceControlBar = ({
  isMicOn,
  isVideoOn,
  isScreenSharing,
  onToggleMic,
  onToggleVideo,
  onToggleScreenShare,
  onEndCall,
}: VoiceControlBarProps) => {
  const handleEndCall = () => {
    if (onEndCall) {
      onEndCall();
    } else {
      // 기본 동작: 뒤로가기
      window.history.back();
    }
  };

  return (
    <div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-10">
      <div className="flex items-center gap-4 bg-gray-800 rounded-full px-6 py-4">
        {/* 마이크 */}
        <button
          onClick={onToggleMic}
          className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
            isMicOn
              ? "bg-gray-600 hover:bg-gray-500"
              : "bg-red-500 hover:bg-red-600"
          }`}
          aria-label={isMicOn ? "마이크 끄기" : "마이크 켜기"}
        >
          {isMicOn ? (
            <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
              <path
                fillRule="evenodd"
                d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z"
                clipRule="evenodd"
              />
            </svg>
          ) : (
            <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
              <path
                fillRule="evenodd"
                d="M2.293 2.293a1 1 0 011.414 0L7 5.586V4a3 3 0 116 0v4c0 .57-.16 1.104-.44 1.563l1.828 1.828A6.966 6.966 0 0015 8a1 1 0 012 0 8.94 8.94 0 01-1.22 4.522l1.927 1.927a1 1 0 01-1.414 1.414L3.707 3.707a1 1 0 010-1.414zM10 11.414L7.586 9A3.001 3.001 0 007 8v3a3 3 0 003 .414z"
                clipRule="evenodd"
              />
            </svg>
          )}
        </button>

        {/* 비디오 */}
        <button
          onClick={onToggleVideo}
          className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
            isVideoOn
              ? "bg-gray-600 hover:bg-gray-500"
              : "bg-red-500 hover:bg-red-600"
          }`}
          aria-label={isVideoOn ? "비디오 끄기" : "비디오 켜기"}
        >
          {isVideoOn ? (
            <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
              <path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z" />
            </svg>
          ) : (
            <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
              <path
                fillRule="evenodd"
                d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
                clipRule="evenodd"
              />
            </svg>
          )}
        </button>

        {/* 화면 공유 */}
        <button
          onClick={onToggleScreenShare}
          className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
            isScreenSharing
              ? "bg-green-500 hover:bg-green-600"
              : "bg-gray-600 hover:bg-gray-500"
          }`}
          aria-label={isScreenSharing ? "화면 공유 중지" : "화면 공유 시작"}
        >
          <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
            <path
              fillRule="evenodd"
              d="M3 4a1 1 0 011-1h12a1 1 0 011 1v8a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 3v6h10V7H5z"
              clipRule="evenodd"
            />
          </svg>
        </button>

        {/* 통화 종료 */}
        <button
          onClick={handleEndCall}
          className="w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors"
          aria-label="통화 종료"
        >
          <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
            <path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
          </svg>
        </button>
      </div>
    </div>
  );
};

VoiceGrid

typescript
import { VoiceParticipantCard } from "./VoiceParticipantCard";
import { VoiceParticipant } from "../../../../../types/voiceChannelTypes";

interface VoiceGridProps {
  participants: { [userId: string]: VoiceParticipant };
  gridLayout: string;
  gridRows: string;
  currentUserId?: string; // 현재 사용자 ID
  cameraStream?: MediaStream | null; // 카메라 스트림
}

export const VoiceGrid = ({
  participants,
  gridLayout,
  gridRows,
  currentUserId,
  cameraStream,
}: VoiceGridProps) => {
  return (
    <div
      className={`grid ${gridLayout} ${gridRows} gap-4 w-full h-full max-w-6xl`}
    >
      {Object.entries(participants).map(([userId, participant]) => (
        <VoiceParticipantCard
          key={userId}
          participant={participant}
          isCompact={false}
          videoStream={
            userId === currentUserId && participant.isVideoOn
              ? cameraStream || undefined
              : undefined
          }
        />
      ))}
    </div>
  );
};

VoiceParticipantCard

typescript
import { VoiceParticipant } from "../../../../../types/voiceChannelTypes";
import { useEffect, useRef } from "react";

interface VoiceParticipantCardProps {
  participant: VoiceParticipant;
  isCompact?: boolean; // 화면 공유 모드일 때 작은 크기
  videoStream?: MediaStream; // 비디오 스트림
}

export const VoiceParticipantCard = ({
  participant,
  isCompact = false,
  videoStream,
}: VoiceParticipantCardProps) => {
  const videoRef = useRef<HTMLVideoElement>(null);

  // 비디오 스트림 연결
  useEffect(() => {
    if (videoRef.current && videoStream) {
      videoRef.current.srcObject = videoStream;
    }
  }, [videoStream, participant.isVideoOn, participant.username]);

  const avatarSize = isCompact ? "w-12 h-12" : "w-24 h-24";
  const textSize = isCompact ? "text-xs" : "text-sm";
  const iconSize = isCompact ? "w-4 h-4" : "w-8 h-8";
  const namePosition = isCompact ? "bottom-2 left-2" : "bottom-4 left-4";

  return (
    <div
      className={`bg-gray-800 rounded-lg ${
        isCompact ? "p-4 aspect-video" : "aspect-video"
      } flex flex-col items-center justify-center relative overflow-hidden ${
        participant.isSpeaking ? "ring-2 ring-green-400" : ""
      }`}
    >
      {/* 사용자 비디오 또는 아바타 */}
      {participant.isVideoOn && videoStream ? (
        <video
          ref={videoRef}
          autoPlay
          muted
          playsInline
          className="absolute inset-0 w-full h-full object-cover rounded-lg"
        />
      ) : (
        <div
          className={`${avatarSize} bg-gray-600 rounded-full flex items-center justify-center ${
            isCompact ? "text-xl mb-2" : "text-3xl mb-4"
          }`}
        >
          {participant.username[0]}
        </div>
      )}

      {/* 이름과 상태 아이콘 컨테이너 */}
      <div className={`absolute ${namePosition} flex items-center gap-2`}>
        {/* 사용자 이름 */}
        <div className="bg-aurora-voice rounded-full px-5">
          <span className={`${textSize} text-white`}>
            {participant.username}
          </span>
        </div>

        {/* 상태 표시 아이콘들 */}
        <div className={`flex gap-${isCompact ? "1" : "2"}`}>
          {!participant.isMicOn && (
            <div
              className={`${iconSize} bg-red-500 rounded-full flex items-center justify-center`}
            >
              <svg
                className={`${isCompact ? "w-3 h-3" : "w-4 h-4"}`}
                fill="currentColor"
                viewBox="0 0 20 20"
              >
                <path
                  fillRule="evenodd"
                  d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
                  clipRule="evenodd"
                />
              </svg>
            </div>
          )}
          {!participant.isVideoOn && (
            <div
              className={`${iconSize} bg-gray-500 rounded-full flex items-center justify-center`}
            >
              <svg
                className={`${isCompact ? "w-3 h-3" : "w-4 h-4"}`}
                fill="currentColor"
                viewBox="0 0 20 20"
              >
                <path
                  fillRule="evenodd"
                  d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
                  clipRule="evenodd"
                />
                <path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
              </svg>
            </div>
          )}
        </div>
      </div>

      {/* 발언 중 애니메이션 */}
      {participant.isSpeaking && (
        <div className="absolute inset-0 border-2 border-green-400 rounded-lg animate-pulse"></div>
      )}
    </div>
  );
};

결과

카메라 켜기 전

image.png

image.png

카메라 켠 후

image.png

image.png

다음과 같이 카메라 기능이 정상적으로 작동하게 되었다! 이제 다음에는 마이크랑 헤드셋 부분 빠르게 만들어보자!

태그

#aurora#Next.js