본문 바로가기

Project

회사 프로젝트 후기 - (7) 구강 카메라 스트림 상태 체크로 사용자 경험 개선

반응형

 

현재 재직 중인 회사는 바이오 형광 이미징 기술을 활용한 구강 카메라를 생산하며, 이를 치과 병·의원 등을 대상으로 판매하는 기업입니다.

 

개발 중인 웹 기반 구강 건강 관리 플랫폼인 **링크덴스(Linkdens)**는 치아 사진 촬영, 치아 건강 분석, 진료 이력 확인, 환자에게 진료 정보를 전송할 수 있는 클라우드 기반 서비스형 소프트웨어(SaaS)입니다.  (참고자료 : LINKDENS&캠프로 사용법 안내 영상)

 

링크덴스는 구강 카메라의 미국 시장 진출을 위해 미국판 버전을 준비하고 있습니다. 이 과정에서 개발팀은 기존 제품의 몇 가지 핵심적인 문제점을 개선하고자 했습니다. 특히 카메라 연결이 끊어질 때 발생하는 사용자 경험의 저하 문제는 긴급하게 해결해야 할 과제였습니다.

 

1. 문제의 본질

OLD 버전 : 오른쪽 상단의 Reload 버튼의 존재가 이 페이지가 문제가 있음을 알려주고 있다.

 

구강 카메라의 핵심 문제는 LED의 발열 관리 및 내구성 유지를 위해 약 2분 동안 사용하지 않을 경우 자동으로 슬립 모드(SLEEP MODE)로 전환된다는 것에서 시작됩니다.

 

슬립 모드인 상태에서 사용자가 다시 버튼을 누를 경우, 카메라 연결이 일시적으로 끊겼다가 재연결되면서 카메라 스트림이 재전송됩니다. 슬립 모드에서 카메라가 다시 활성화되는 과정에서 발생하는 문제들은 다음과 같습니다.

 

 

1. 슬립 모드로 인한 화면 정지 문제 : 슬립 모드로 전환되면서 화면에 마지막으로 표시된 이미지가 계속 유지되어, 화면이 정지된 것처럼 보이는 오류가 발생합니다. 이는 사용자가 카메라 작동이 중단되었다고 잘못 인식할 수 있는 여지를 제공합니다.

 

2. 재연결시 혼란 :  슬립 모드에서 카메라의 버튼을 누르면, 카메라가 PC의 장치 관리자에서 일시적인 언마운트 및 재마운트 과정에서 카메라 연결이 끊어졌다는 정보를 제공하는 Toast 메세지가 먼저 나타났다가, 곧이어 카메라가 다시 연결되었다는 메세지가 표시됩니다. 이는 사용자에게 혼란을 줄 수 있으며, 제품의 신뢰성을 저하시킬 수 있습니다.

 

 

개발팀이 존재하기 전부터 카메라의 하드웨어와 소프트웨어 모두 외주를 통해 개발되었기 때문에, 카메라 스트림의 연결 상태를 실시간으로 모니터링하고, 연결이 끊어졌을 때 적절한 사용자 피드백을 줄 수 있는 API가 존재하지 않는 상황에서 위의 2가지 문제를 프론트엔드 입장에서 해결해야 하는 상황이었습니다.

 

2. 프로젝트의 목표

구강 카메라의 스트림을 표시하는 문제가 사용성을 해치는 문제를 해결하기 위한 프로젝트의 목표는 크게 두 가지였습니다.

 

 

1. 실시간 스트림 상태 감지 : 카메라가 PC에 연결되어 있을 때, 실시간으로 카메라의 스트림 상태를 모니터링하고, 끊김이나 문제를 즉시 탐지하는 메커니즘을 구현하는 것입니다.

 

2. 사용자 정보 전달 개선 : 스트림이 중단되었을 때, 사용자에게 정확하고 효과적인 피드백을 전달하는 방법을 마련하여 사용자가 혼란스럽지 않도록 개선하는 것입니다.

 

 

이 두 가지 목표를 안정적으로 링크덴스(Linkdens) 플랫폼에 적용하는 것이 이번 프로젝트의 가장 큰 도전 과제였습니다.

 

3. 기존 구현 기술

Windows 인터페이스의 navigator 객체는 WebRTC의 요소로 브라우저의 비디오 스트리밍 서비스에 사용됩니다.

일반적인 장치 목록 확인과 카메라 스트림 상태 구현 과정은 다음과 같습니다.

 

 

1. 장치 목록 가져오기navigator.mediaDevices.enumerateDevices() 메서드를 사용하여 PC의 장치 관리자에 연결된 카메라의 목록을 가져왔습니다. 이 단계에서는 사용 가능한 모든 비디오 입력 장치를 확인할 수 있습니다.

 

2. MediaStream API 사용navigator.mediaDevices.getUserMedia() 메서드를 사용하여 카메라로부터의 스트림을 가져왔습니다. 이 API를 통해 비디오 스트림을 성공적으로 얻은 후 스트림 객체를 HTML의 video 태그와 연결하여 브라우저에 스트림을 표시합니다.

 

3. 카메라 스트림 체크 : navigator.mediaDevices.getUserMedia()를 사용하여 카메라 스트림을 가져오고, 스트림의 onended 이벤트를 통해 카메라 연결이 끊어졌을 때 사용자에게 알림을 표시합니다. 

 

 

현재의 프로젝트에서는 아래의 코드처럼는 0.5초 단위로 PC의 장치 관리자에 연결된 카메라의 목록만을 주기적으로 체크하고 있었습니다. 실제 카메라의 스트림 상태를 모니터링하기 위해 더 적절한 방법을 활용하고, 스트림이 끊어졌을 때 사용자가 그것을 명확히 인지할 수 있도록 해야 합니다.

 

// 0.5초 단위로 연결 장치 목록 확인
useInterval(() => {
  if (selectedTooth) {
    navigator.mediaDevices
      .enumerateDevices()
      .then((devices) => {...}
      .catch(() => {});
  }
}, 500);

// 최초 실행시에 카메라 허용
useEffect(() => {
  try {
    navigator.mediaDevices.getUserMedia({ video: true });
  } catch {}
  return () => removeEventListener();
}, []);

 

 

4. 문제 해결 접근법과 구현 방법

문제 해결을 위해 여러 가지 접근 방안을 고려했습니다. 특히 카메라 스트림 상태를 실시간으로 감지하고, 끊어졌을 경우 사용자가 이해할 수 있도록 명확하게 전달하는 데 중점을 두었습니다.

 

우선 구강 카메라의 종류를 타입으로 명확하게 선언하고 스트림 연결 여부(isStreamActive)와 카메라 스트림을 비디오 태그에 연결한 이후 이를 모니터링하는 트리거 변수(needStreamCheck)를 선언하여 카메라 스트림 상태를 체계적으로 관리했습니다.

 

스트림 상태 체크 대상 카메라는 **QRAYPEN(큐레이 펜씨)**와 **QRAYCAM(큐레이 캠프로)**입니다.

 

const initialDevicesStatus: DevicesStatus = {
    QRAYPEN: {
        isStreamActive: false,
        streamObject: null,
        needStreamCheck: null,
    },
    QRAYCAM: {
        isStreamActive: false,
        streamObject: null,
        needStreamCheck: null,
    },
};

 

4.1 카메라 스트림 체크 방법

**큐레이 캠프로(QRAYCAM)**는 일반적인 MediaStream 객체의 active 속성을 통해 스트림의 활성 여부를 확인할 수 있습니다. 그러나 **큐레이 펜씨(QRAYPEN)**의 경우에는 스트림이 active 속성과 명확하게 연동되지 않았기 때문에 다른 방법을 사용해야 했습니다. 그래서 여러 속성과 메서드를 확인한 결과, getVideoTracks()의 muted 속성에서 스트림 상태를 감지할 수 있었습니다.

 

아래는 카메라별 스트림 체크 방법입니다.

 

 

1. QRAYCAM(큐레이 캠프로) : getUserMedia MediaStream 객체 내부의 active 속성을 통해 스트림 상태를 확인했습니다. 이를 통해 스트림이 활성 상태인지 확인할 수 있었습니다.

 

2. QRAYPEN(큐레이 펜씨) : MediaStream 객체에서 getVideoTracks() 메서드를 호출한 후 반환된 배열의 첫 번째 요소([0])에  muted 속성을 사용하여 스트림 상태를 감지했습니다. 이 방식으로 스트림이 정상적으로 작동하고 있는지 체크할 수 있었습니다.

 

 

이와 같은 방식으로 스트림 상태를 실시간으로 체크하여 문제가 발생했을 때 사용자에게 명확하게 알림을 제공함으로써 사용자 경험을 개선하려고 했습니다.

 

const deviceStream = await navigator.mediaDevices.getUserMedia({
    video: {
        deviceId: { exact: deviceId },
        width: { min: 1280 },
        height: { min: 720 },
    },
});

const { active } = deviceStream;
const { muted } = deviceStream.getVideoTracks()[0];

 

4.2 카메라 스트림 모니터링

video 태그에 스트림 객체를 연결한 후, 위의 카메라 스트림 체크 속성인 active와 muted의 상태를 주기적으로 모니터링해야 했습니다. 이를 위해 deviceStatus의 streamObject를 변수 localStream에 저장하고, active와 muted 속성의 값에 따라 스트림 상태를 실시간으로 확인했습니다.

 

아래는 카메라 스트림을 모니터링하는 구현 방법입니다. active와 muted 속성을 주기적으로 확인하여 카메라 스트림 상태를 실시간으로 모니터링하고, 연결 상태에 따라 사용자에게 명확한 정보를 제공함으로써 사용자 경험을 더욱 개선할 수 있었습니다.

 

const getConnectedDevices = async (): Promise<void> => {
  try {
    // 1. 카메라 장치 목록 가져오기
    const devices = await navigator.mediaDevices.enumerateDevices();
    const cameraList = devices.filter((device) => device.kind === "videoinput")
    .map(makeSimpleLabel) // 라벨 간소화
    .filter((label) => label)
    .map((label) => ({ cameraLabel: label }));

    // 2. 장치 목록 업데이트
    setDeviceList(cameraList);

    // 3. 기본 카메라 설정 (선택된 카메라가 없으면)
    if (!selectedCameraVar()) {
      setSelectedCamera();
    }

    // 4. QRAYCAM 처리
    handleQraycamStream(devicesStatus["QRAYCAM"], needCamproStreamCheck, isCamproStreamOn);

    // 5. QRAYPEN 처리 (플랫폼별로 분기)
    if (platformVar() === "Windows") {
      handleQraypenStreamWindows(devicesStatus["QRAYPEN"], needPencStreamCheck, isPencStreamOn);
    } else if (platformVar() === "macOS") {
      handleQraypenStreamMacOS(devicesStatus["QRAYPEN"], needPencStreamCheck, isPencStreamOn);
    }

    // 6. 나머지 카메라 처리 (QRAYCAM, QRAYPEN 제외)
    if (!needCamproStreamCheck || !needPencStreamCheck) {
      try {
        await Promise.all(
          cameraList.map(async (device) => {
            const { isSuccess, needStreamCheck, isStreamActive, streamObject } = await getDeviceStream(
              device.cameraLabel,
              device.cameraId,
            );

            if (isSuccess) {
              // 6.1 장치 상태 업데이트
              updateDeviceStatus(device.cameraLabel, needStreamCheck, isStreamActive, streamObject);

              // 6.2 특수 장치 (QRAYCAM, QRAYPEN) 자동 설정
              handleSpecialDevice(device.cameraLabel, isStreamActive);
            }
          })
        );
        setSelectedCamera();
      } catch (error) {
        console.error("스트림 불러오기 실패 : ", error);
      }
    }
  } catch (error) {
    console.error("장치 목록화 오류 : ", error);
  }
};

 

4.3 사용자 인터페이스

사용자 인터페이스는 카메라 연결 상태 및 스트림 활성화 여부를 명확하게 구분할 수 있도록 설계되었습니다. 이를 통해 사용자는 시스템의 상태를 즉시 파악할 수 있으며, 필요한 조치를 빠르게 취할 수 있습니다. 두 가지 주요 상황을 UI에 효과적으로 반영하고자 하였습니다.

 

 

1. 카메라 미연결 상태 : 연결 가능한 카메라가 없는 경우, 사용자에게 이를 명확하게 알려주는 인터페이스를 제공합니다. 이 상태에서는 사용자가 쉽게 문제를 인식하고, 필요한 조치를 취할 수 있도록 도와주는 안내 문구가 화면에 표시됩니다.

 

 

 

2. 활성화된 스트림의 카메라 표시: 스트림이 활성화된 카메라만을 UI상에 표시함으로써, 사용자는 현재 작동 중인 카메라를 한눈에 파악할 수 있습니다. 이러한 접근 방식은 시스템의 효율성을 높이고, 사용자가 필요한 정보만을 얻을 수 있게 합니다.

 

 

 

메인 화면의 복잡성은 최소화하되, 사용자가 촬영에 필요한 정보를 놓치지 않도록 효율화했습니다. 예를 들어, 복잡한 안내문이나 잦은 Toast 메세지 대신, 촬영 모듈의 직관적이고 자연스러운 UI를 통해 필요한 정보를 전달합니다. 이러한 방식은 사용자가 시스템을 보다 쉽고 효율적으로 사용할 수 있게 하며, 사용자 경험을 향상시키는 데 도움을 줍니다.

 

4.4 커스텀 훅으로 구성

위에서 언급된 로직을 커스텀 훅으로 구현하여, 카메라 스트림의 상태를 효과적으로 모니터링하고 관리할 수 있도록 모듈화하였습니다. 이러한 접근 방법은 UI의 변경에도 유연하게 대응할 수 있게 해주며, 필요한 정보를 UI 컴포넌트에 정확하게 전달하여 사용자 경험을 향상시킵니다.

 

커스텀 훅은 다음과 같은 기능들을 수행합니다.

 

 

1. 실시간 스트림 모니터링: 카메라의 연결 상태와 스트림의 활성화 상태를 지속적으로 체크합니다. 이는 시스템의 신뢰성을 보장하며, 사용자가 실시간으로 시스템의 상태를 파악할 수 있도록 합니다.

 

2. 에러 핸들링: 스트림 중단 또는 기타 예외 상황이 발생했을 때, 적절한 오류 메시지와 함께 사용자에게 알림을 제공합니다. 이는 문제 해결 과정에서 사용자의 혼란을 최소화하고, 즉각적인 조치를 유도합니다.

 

3. UI와의 독립성: 커스텀 훅을 통해 로직과 UI를 분리함으로써, UI 개발자는 디자인 변경에 집중할 수 있고, 로직 개발자는 기능 개선에 집중할 수 있습니다. 이는 개발 과정의 효율성을 높이며, 유지보수를 용이하게 합니다.

 

 

이 커스텀 훅은 애플리케이션 전반에 걸쳐 일관된 로직을 제공하며, 사용자가 어떤 UI 요소를 사용하더라도 동일한 수준의 정보와 기능성을 보장합니다. 이와 같은 방식은 사용자 경험을 일관되고 직관적으로 만드는 데 크게 기여합니다.

 

/**
 * 연결된 장치 목록과 상태를 관리하는 커스텀 훅입니다.
 *
 * @returns {UseConnectedDevicesProps} 장치목록, 상태 및 업데이트 함수를 포함하는 객체입니다.
 */
const useConnectedDevices = (): UseConnectedDevicesProps => {
	const [deviceList, setDeviceList] = useState<AutoConnectedDeviceInfo[]>([]);

	const initialDevicesStatus: DevicesStatus = {
		QRAYPEN: {
			isStreamActive: false,
			streamObject: null,
			needStreamCheck: null,
		},
		QRAYCAM: {
			isStreamActive: false,
			streamObject: null,
			needStreamCheck: null,
		},
	};

	const [devicesStatus, setDevicesStatus] = useState<DevicesStatus>(initialDevicesStatus);

	const [needPencStreamCheck, setNeedPencStreamCheck] = useState<boolean>(false);
	const [needCamproStreamCheck, setNeedCamproStreamCheck] = useState<boolean>(false);

	const [isCamproStreamOn, setIsCamproStreamOn] = useState<boolean>(false);
	const [isPencStreamOn, setIPencStreamOn] = useState<boolean>(false);
    .
    .
    .
    
return { deviceList, devicesStatus, getConnectedDevices };