Skip to content

사용자가 스피커의 배치를 조정하면서 공간 음향의 효과를 경험할 수 있는 가상 시뮬레이터입니다.

Notifications You must be signed in to change notification settings

soundrag/soundrag-client

Repository files navigation


Soundrag는 스피커 배치에 따라 소리의 변화를 느낄 수 있는 3D 공간 음향 시뮬레이터 입니다.


프로젝트 로고

배포 입장 버튼


스피커를 이동하거나 최적의 위치를 찾는 것은 시간과 노력이 많이 소요됩니다.
이러한 문제를 해결하기 위해 3D 가상 공간에서 스피커를 자유롭게 배치할 수 있는 환경을 구현하였습니다.

서버 레포지토리



목차



1. 움직이는 스피커로 소리를 바꾸기

사용자가 3D 공간에서 스피커를 움직이며 소리의 변화를 체험할 수 있도록, 다음과 같은 주요 기능을 구현하였습니다.

  • 2D 화면에서의 3D 모델 움직임
    사용자가 마우스를 이용해 3D 모델을 드래그할 수 있도록, 2D 화면 좌표3D 공간 좌표로 변환하고 해당 좌표를 마우스 드래그 이벤트와 연결하는 방식으로 움직임을 구현하였습니다.

  • 3D 공간에서 위치에 따른 소리 변화
    스피커의 위치에 따라 소리가 어떻게 변화하는지 체험할 수 있도록 세 가지 주요 음향 특성을 적용하였습니다.

    • 거리 기반 소리 변화: 리스너와 스피커 사이의 거리에 따라 소리 감쇠효과를 반영
    • 좌우 배치에 따른 변화: 스피커가 좌우로 이동함에 따라 스테레오 효과를 조정
    • 천장 및 바닥 배치에 따른 변화: 스피커가 천장에 가까울수록 고주파 강조, 바닥에 가까울수록 저주파 강조

    세 가지 특성을 적용한 이유는 사용자가 스피커를 이동하면서 소리의 변화를 직관적으로 느끼기 위함입니다.


2D 화면에서 3D 모델 움직임 3D 공간에서 위치에 따른 소리 변화
2D 화면에서 3D 모델 움직임 3D 공간에서 위치에 따른 소리 변화

1-1. 마우스로 3D 스피커 모델 조작하기

사용자의 마우스 움직임에 따라 2D 화면에서 3D 모델을 움직이기 위해 총 3단계가 필요합니다.


스크린샷 2024-12-22 오후 1 29 31

사용자가 3D 모델의 스피커의 위치를 조정하여 소리의 변화를 경험할 수 있습니다. 스피커를 움직이기 위해서는 사용자가 2D 화면에서 3D 스피커 모델의 움직임을 직접 조작해야 하는 것이 주요했습니다.

(1) 다양한 해상도에서 매끄럽게 움직이려면?

다양한 해상도에 대응하기 위해서는 NDC(Normalized Device Coordinates) 좌표로 변환해야 합니다.

웹에서 사용 가능한 이 프로젝트는 사용자들이 다양한 해상도와 화면 비율을 갖고 있는 모니터로 접근합니다. 이러한 다양성 때문에 고정된 화면 크기를 기준으로 좌표를 계산하면 다른 해상도나 비율의 화면에서는 3D 공간에서의 위치 계산이 부정확해질 수 있습니다. 예를 들어, 개발 당시 설정된 화면의 해상도와 비율이 사용자의 모니터와 다를 경우, 사용자가 마우스로 모델을 조작하려 할 때 모델이 의도하지 않은 방향으로 이동하는 문제가 발생할 수 있습니다. 이는 화면 좌표와 3D 공간 좌표의 매핑 오류로 인해 사용자 경험에 혼란을 줄 수 있습니다.

이러한 문제점을 해결하기 위해 프로젝트 화면의 좌표를 NDC 좌표로 변환하여 사용자의 모니터 해상도와 비율에 맞는 독립적인 좌표를 설정합니다. NDC 좌표로 변환하는 과정은 화면의 모든 좌표를 -1에서 1 사이의 값으로 정규화하는 것입니다.


프로젝트 화면 NDC 좌표 변환 과정

프로젝트 화면 NDC 좌표 변환


(2) 2D 화면에서 3D 모델 좌표 구하기

2D 화면에서 보이는 3D 공간의 모델 좌표를 구하기 위해 광선(Ray) 을 생성합니다.

2D 화면에서 3D 좌표를 직접 구할 수 없는 이유는 깊이 정보가 없기 때문입니다. 이를 해결하기 위해 2D 화면과 3D 공간을 연결하는 광선 투사 방식을 사용합니다. 화면상의 마우스 위치에서 3D 공간으로 광선을 발사하여, 광선이 3D 모델과 만나는 지점의 거리를 측정합니다. 이를 통해 마우스 위치에 대응하는 3D 공간의 [x, y, z] 좌표를 얻을 수 있습니다.


광선 생성

광선 생성을 통한 z축 좌표 계산


(3) 3D 모델과 마우스 움직임의 실시간 상호작용

3D 모델과 사용자의 마우스 움직임이 서로 상호작용하기 위해 3D 모델의 [x, y, z] 좌표를 마우스 이벤트와 연결합니다.

사용자가 마우스로 스피커를 배치할 수 있도록 마우스 드래그 이벤트를 활용합니다. 드래그를 시작할 때 마우스 위치를 기준으로 스피커의 초기 좌표를 저장하고, 드래그 중에는 마우스 움직임에 따라 스피커의 [x, y, z] 좌표를 실시간으로 업데이트하여 화면상의 위치 변화를 반영합니다.


마우스 이벤트와 3D 모델 좌표 실시간 연동

마우스 이벤트와 3D 모델 좌표 실시간 연동


위와 같은 3단계의 과정을 통해 사용자는 2D 화면에서 3D 스피커 모델을 마우스로 드래그하여 직접 배치할 수 있습니다.



1-2. 스피커의 위치로 소리를 바꾸는 방식

공간에 따른 소리의 변화는 복잡한 과학적 원리가 내재되어 있습니다. 사용자가 조작한 스피커 위치에 따른 소리의 변화를 제공하기 위해 과학적 원리와 연관된 세 가지 소리의 특징만을 활용하였습니다.

  1. 리스너와 스피커와의 거리에 따른 소리 변화

  2. 스피커의 좌우 배치에 따른 소리 변화

  3. 스피커의 천장, 바닥 배치에 따른 소리 변화

이 프로젝트에서는 사용자가 이러한 원리를 명확하게 체험할 수 있도록, 위와 같은 위치 요소를 통해 구현하였습니다. 아래 각 변화에 해당하는 내용을 자세히 설명하겠습니다.

(1) 멀리 갈수록 약해지는 소리

리스너로부터 스피커까지의 x, y, z축 거리를 활용하여 소리의 감쇠효과를 구현합니다.

소리의 감쇠효과는 리스너로부터 소리의 근원지가 멀어지면 볼륨이 감소하고 거리가 가까워지면 볼륨이 증가하는 현상을 의미합니다. 이 프로젝트에서는 소리의 근원지를 스피커로 설정하여 아래와 같이 구현하였습니다.


거리가 가까운 경우 거리가 먼 경우
스피커와 리스너가 가까운 경우 스피커와 리스너가 먼 경우

사용자는 리스너로부터 스피커를 가까이 배치할 경우 볼륨이 증가하고 멀리 배치하는 경우 볼륨이 감소하는 현상을 경험할 수 있습니다. 리스너로부터 스피커 사이의 거리를 구하기 위해 실시간으로 각 모델의 [x, y, z] 좌표를 입력하여 소리의 감쇠효과를 보여줍니다.

(2) 좌우 위치에 따라 달라지는 소리

리스너로부터 스피커까지의 x, z축 거리를 활용하여 스피커의 좌우 위치에 따른 소리의 세기차이를 구현합니다.

스피커가 리스너의 왼쪽에 배치되면 왼쪽에서 들리는 소리가 강하게 오른쪽에 배치되면 오른쪽에서 소리가 강하게 들리는 현상을 기반으로 합니다. 이는 스피커의 위치에 따라 가까운 쪽 귀로 소리가 더 강하게 들어오고, 머리가 다른 쪽 귀로의 소리 전달을 약간 차단하기 때문입니다.

이 프로젝트에서는 리스너를 기준으로 스피커의 좌우 위치를 파악하기 위해 cos 함수를 활용하였습니다.

cos 함수 활용 방식은 아래와 같습니다.

  1. 리스너를 중심으로 스피커와의 x축 ,z축 거리를 입력합니다.
  2. 입력된 거리를 바탕으로 스피커와 리스너 사이의 각도를 계산합니다.
  3. 계산된 각도를 cos 함수에 대입하여 결과 값을 통해 스피커의 좌우 위치를 파악합니다.
cos (각도) 위치
cos (180°) -1 왼쪽
cos (90°) 0 중앙
cos (0°) 1 오른쪽

이 방식을 통해 cos 값이 -1에 가까우면 스피커가 리스너를 기준으로 왼쪽에, 1에 가까우면 오른쪽에 배치된 것으로 분류합니다.


왼쪽 배치 오른쪽 배치
스피커가 리스너를 기준으로 왼쪽 배치 스피커가 리스너를 기준으로 오른쪽 배치

이와 같은 계산을 통해 사용자는 스피커를 왼쪽에 배치할 때 왼쪽 소리가 강조되고 오른쪽에 배치할 때 오른쪽 소리가 강조되는 경험을 할 수 있습니다. 즉, 리스너로부터 스피커의 x, z축 거리를 활용하여 스피커 좌우 배치에 따른 스테레오 효과를 제공합니다.

(3) 천장과 바닥의 소리 차이: 주파수

스피커의 주파수 음역대의 특징을 활용하여 수직 배치에 따른 소리의 차이를 구현합니다.

사용자에게 스피커 배치에 따라 달라지는 소리의 차이를 직관적으로 제공하기 위해, 단순히 볼륨 차이만이 아니라 주파수 음역대의 변화를 추가 구현하기로 결정했습니다. 특히, 스피커의 위치에 따라 주파수 음역대가 어떻게 달라지는지 명확한 구분이 필요했습니다.

이를 구분하기 위해 먼저 주파수 음역대의 특징을 살펴보았습니다.

  • 고주파 음역대는 짧은 파장을 가지기 때문에 다른 물체에 쉽게 흡수되어 부드러운 방의 바닥에서 약하게 전달됩니다.
  • 저주파 음역대는 긴 파장을 가지며, 이로 인해 딱딱한 천장에 부딪혀도 쉽게 반사되어 그대로 전달됩니다.

이 프로젝트에서는 이러한 주파수 음역대의 특성을 활용하여 사용자가 스피커의 수직 위치 변경에 따라 고주파 음역대가 어떻게 다르게 전달되는지 체험할 수 있게 하였습니다.


바닥 배치 천장 배치
스피커 바닥 배치 스피커 천장 배치

바닥 가까이에 설치된 스피커는 고주파 음역대가 흡수되어 약하게 전달되어 상대적으로 저주파 음역대를 강조한 풍부하고 깊은 베이스를 경험할 수 있도록 설계하였습니다. 반면 천장 가까이에 설치된 스피커는 고주파 음역대가 흡수되지 않고 그대로 반사되어 고주파 음역대를 강조한 섬세하고 선명한 소리를 제공하도록 구현하였습니다.


위와 같은 세 가지 위치 요소를 고려한 스피커 배치로 사용자에게 다양한 음향 변화를 제공합니다.



2. 사용자 편의성을 위한 도전들

이 프로젝트에서 사용자 편의성을 증진시키기 위해, 다음과 같은 기능을 추가하였습니다.

  • 자동 저장: 시스템 오류, 저장 버튼 누락 등 예상치 못한 상황 속에서 사용자의 스피커 배치 데이터를 안전하게 저장하기 위해 자동 저장 방식을 도입하였습니다. 자동 저장은 사용자가 스피커의 위치를 이동하고 일정 시간동안 더 이상 움직이지 않을 때, 실행됩니다. 또한 애플리케이션의 접근성을 높이기 위해 로그인을 한 사용자의 경우 스피커의 위치 정보와 관련된 데이터는 서버로 저장하고 로그인을 하지 않은 사용자의 경우 위치 정보를 로컬 스토리지에 저장하도록 구현하였습니다.

  • 프리로드: 느린 네트워크 환경에서 발생할 수 있는 3D 애플리케이션의 불편함을 최소화하기 위해 필요한 3D 리소스를 사전에 로드하는 프리로드 기능을 도입하였습니다. 이 기능을 구현하기 위해 프리 로드로 불러온 3D 모델과 텍스처의 경로를 캐싱하는 React Three Drei의 useGLTF 훅을 사용하였습니다. 경로를 캐싱하는 useGLTF의 동작원리를 활용하여 같은 모델이 여러 개인 경우 재사용할 수 있도록 하였고 불필요한 리소스 네트워크 요청을 줄일 수 있었습니다.

  • 사용자 인증 상태 유지: 사용자가 새로고침하거나 인증 만료와 같은 상황이 발생더라도 끊김 없는 인증 상태를 유지하기 위해 Axios 인터셉터 로직을 구현하였습니다. 인터셉터는 요청 전에 Firebase 인증 토큰을 발급받아 모든 요청에 추가하도록 하였습니다. 만약 인증이 만료가 된 경우, 응답 후에 토큰을 갱신하거나 요청을 재시도하는 로직을 구현하여 사용자 경험에 연속성을 보장하도록 구현하였습니다. 이를 통해 사용자가 로그인 절차를 반복하는 번거로움을 제거하였습니다.

  • 낙관적 업데이트: 사용자가 로그인 후 서버와 데이터를 동기화하는 과정에서 데이터를 UI에 즉시 반영하여 부드러운 사용자 경험을 제공하는 낙관적 업데이트 방식을 도입하였습니다. 만약 동기화에 실패할 경우, 이전 데이터로 롤백하여 데이터 무결성을 유지하고, 사용자에게 적절한 안내를 통해 작업 진행 상황을 명확하게 알려주었습니다. 이를 통해 서버 요청 작업이 오래 걸리더라도 일관된 데이터 처리를 보장하며 화면 업데이트가 지연되지 않도록 구현하였습니다.


2-1. 잊어버려도 괜찮아요, 자동 저장

(1) [고민] 저장 버튼만 존재하는 불편한 세상

만약 저장 버튼으로만 사용자가 스피커 위치 정보를 저장할 수 있다면 어떤 일들이 발생할까요?

  • 데이터 손실 위험
    사용자가 스피커를 배치하다가 시스템 오류, 네트워크 문제, 또는 저장 버튼 클릭을 잊고 브라우저를 닫는 경우, 작업 중인 데이터를 잃을 가능성이 큽니다. 이로 인해 사용자는 작업을 처음부터 다시 시작해야 하는 불편함을 겪을 수 있습니다.


    로그 아웃 저장 버튼

    로그아웃 상태에서 이용할 수 없는 저장 버튼


또한, 로그인 기능과 연동되어 있는 "현재 공간 저장" 버튼의 경우, 로그인하지 않은 사용자들은 사용할 수 없기 때문에 로그인 여부에 따른 적합한 저장 방식을 별도로 고민하게 되었습니다. 이를 위해, 버튼에 의존하지 않고 데이터 손실의 위험을 줄이며 로그인하지 않은 사용자들도 저장할 수 있는 방식으로 자동 저장 기능을 도입하게 되었습니다.


(2) [구현] 자동 저장이 실행될 조건과 저장할 위치

자동 저장 기능을 설계하고 구현하는데 있어 가장 중요한 부분은 저장 조건을 명확히 정의하는 것이었습니다. 이를 통해 중복 저장을 방지하고 불필요한 작업을 줄일 수 있었습니다.

  • 저장 조건

    • 사용자가 스피커의 위치나 회전 값을 변경한 경우,
    • 변경된 정보가 기존 정보와 중복되지 않는 경우,
    • 5초 동안 사용자가 스피커의 위치나 회전 값을 변경하지 않는 경우,

이러한 조건을 모두 성립한 경우, 자동 저장을 실행합니다.


5초 동안 사용자가 스피커의 위치나 회전 값을 변경하지 않는 것을 감지하고 정보를 저장하기 위해 일정 시간 이후에 콜백 함수를 실행하는 비동기 함수 setTimeout을 활용하기로 결정하였습니다. 하지만 리액트 환경에서 setTimeout만 사용할 경우, 다음과 같은 문제점을 맞닥뜨릴 수 있었습니다.

  • 메모리 누수
    사용자가 스피커의 위치나 회전 정보를 변경할 때마다 새로운 setTimeout 함수가 실행될 때, 만약 컴포넌트가 언마운트되었을 때 타이머를 정리하지 않으면 기존 타이머가 계속 남아 메모리 누수가 발생할 수 있습니다.

이러한 문제는 같은 저장 작업이 여러 번 실행되어 중복 저장이 발생하거나, 사용자의 의도와 다르게 데이터가 저장되어 데이터 무결성을 해치는 원인이 될 수 있습니다.

이 문제를 해결하기 위해 useRefsetTimeout과 함께 활용하였습니다.

useEffect(() => {
  if (!isDuplicate) {
    timeoutRef.current = setTimeout(() => {
      saveChanges();
    }, delay);
  }

  return () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
  };
}, [positions, rotations]);

useRef를 사용하여 setTimeout의 타이머 ID를 저장하고, useEffect의 클린업 함수에서 타이머를 정리하는 로직을 작성하였습니다. 이렇게 새로운 타이머가 생성되기 전에 기존 타이머를 처리함으로써 메모리 누수를 방지하고, 데이터 무결성을 보장할 수 있었습니다.


또한 더 많은 사용자가 서비스를 이용할 수 있도록 로그인을 한 사용자와 로그인을 하지 않은 사용자에게 같은 경험을 제공할 필요성을 느꼈습니다. 그리하여 저장한 정보를 담는 위치를 아래와 같이 구분하였습니다.

  • 서버: 로그인한 사용자
  • 로컬 스토리지: 로그인하지 않은 사용자

이렇게 저장 위치를 구분지은 이유는 로그인을 한 사용자의 경우, 인증을 통해 다른 디바이스에서도 저장한 정보를 활용하도록 구현하기 위해 서버를 선택하였습니다. 반면, 로그인하지 않은 사용자도 같은 브라우저 환경에서 저장한 정보를 비교적 오래 유지하기 위해 로컬 스토리지를 선택하게 되었습니다.


(3) [결과] 자동 저장이 가져다주는 편리한 세상

이러한 자동 저장 기능을 통해 사용자는 예상치 못한 시스템 오류나 네트워크 문제로 인한 데이터 손실 위험을 크게 줄일 수 있었습니다. 특히, 사용자가 저장 버튼을 누르는 추가적인 작업 없이도 변경 사항이 자동으로 저장되기 때문에 작업에 온전히 집중할 수 있는 환경을 제공할 수 있었습니다. 이러한 접근 방식은 Google Docs와 같은 현대적인 웹 애플리케이션에서 필수적으로 사용되는 자동 저장 기술을 효과적으로 도입하여 결과적으로, 사용자 경험을 향상시키는데 기여할 수 있었다고 생각합니다.

Google Docs Soundrag
Google Docs 자동 저장 기능 Soundrag 자동 저장 기능

2-2. 빠른 속도의 비밀, 프리로드

(1) [고민] 느린 네트워크를 극복하기 위한 고민

이 프로젝트에서 3D 리소스(모델 및 텍스처)는 매우 중요한 요소입니다. 이는 사용자가 3D 모델을 조작해야 하는 핵심 기능과 직결되기 때문입니다. 하지만 느린 네트워크 환경에서는 3D 리소스가 로드되지 않아 빈 화면이 사용자에게 노출되는 시간이 길어질 수 있습니다. 이러한 상황이 반복되면 사용자 경험이 심각하게 저하될 가능성이 높다고 생각합니다.


느린 네트워크 환경에서 마주한 빈 화면

느린 네트워크 환경에서 마주한 빈 화면


이러한 문제를 해결하기 위해 3D 리소스를 최대한 빠른 시점에 로드해야할 필요가 있다고 판단하였고 프리로드 기능을 도입하게 되었습니다.

(2) [구현] 미리 불러오고 저장까지 해준다고?

프리로드는 애플리케이션 초기 단계에서 사용자가 탐색하게 될 필요한 리소스를 미리 불러오는 과정으로, 빈 화면 노출과 미완성 렌더링과 같은 문제점을 해결하기 위해 아래와 같이 구현하였습니다.

  • 진입 페이지 구현
    애플리케이션이 실행되면 가장 먼저 진입 페이지를 렌더링하도록 설계하였습니다.이 진입 페이지에서 사용자 인터페이스를 탐색하기 전에 필요한 모든 3D 리소스를 백그라운드에서 프리로드하도록 구현하였습니다. 이를 통해 사용자가 진입 페이지에서 다른 페이지로 전환하더라도 필요한 3D 모델과 텍스처가 즉시 렌더링될 수 있도록 보장하였습니다.

    진입 페이지에서 3D 리소스 프리로드 진행

    진입 페이지에서 3D 리소스 프리로드 진행

  • useGLTF
    프리로드 기능은 React Three DreiuseGLTF 훅을 활용하여 구현하였습니다. useGLTFReact Three FiberuseLoader를 래핑한 훅으로, preload 메서드를 통해 지정된 경로의 GLTF 파일을 미리 로드하고 메모리에 캐싱하는 작업을 수행합니다. 이를 통해 동일한 리소스를 재사용하며 네트워크 요청을 줄이고, 렌더링 성능을 최적화할 수 있었습니다.

    useGLTF.preload("/models/speaker.gltf");
    useGLTF.preload("/models/listener.gltf");

useGLTF가 데이터를 캐싱하여 재사용하는 과정은 아래와 같이 진행됩니다.

  1. useGLTF는 경로(url)를 기반으로 3D 리소스 데이터를 네트워크 요청으로 가져옵니다.
  2. 가져온 데이터는 브라우저의 메모리 Heap에 객체 형태로 저장됩니다.
  3. React Three Fiber의 내부 Map 객체를 사용하여 파일 경로를 키로 데이터를 저장하고 관리합니다.
  4. 동일한 경로의 파일이 요청될 경우, 네트워크 요청을 생략하고 캐싱된 데이터를 반환합니다.

(3) [결과] 프리로드로 느린 네트워크 환경을 극복하기

이러한 프리로드 기능을 도입하고 진입 페이지를 구현함으로써, 3D 모델과 텍스처를 미리 로드된 상태로 다음 페이지로 전환할 수 있어 사용자가 빈 화면을 경험하는 시간을 효과적으로 줄일 수 있었습니다. 또한, useGLTF의 캐싱 동작을 통해 중복된 네트워크 요청을 방지하고 렌더링 성능을 최적화하여 더욱 원활한 사용자 경험을 제공할 수 있었습니다.

Before After
프리로드 하기 전 프리로드 적용 후

2-3. 잊지 않고 기억해요, 사용자 인증

(1) [고민] 새로고침할 때마다 번거로운 인증하기

만약 새로고침 시 현재 인증 상태가 초기화되어 사용자가 다시 로그인을 해야 한다면, 새로고침할 때마다 사용자가 로그인을 반복해야 하는 불편함을 느낄 수 있습니다. 이러한 반복 인증 과정은 사용자의 피로도를 높이고 결국 서비스 이탈로 이어질 가능성이 높다고 생각하였습니다.


새로고침할 때, 로그인 상태 유지가 안되는 상황

새로고침할 때, 로그인 상태 유지가 안되는 상황


이를 방지하기 위해, 새로고침 시에도 인증 상태를 유지하고, 사용자가 알 수 없는 인증 토큰이 만료된 경우 자동으로 갱신하는 기능 또한 필요하다고 판단하였습니다.

(2) [구현] 사용자는 몰라도 되는 인증 기억 방법

사용자의 인증을 기억하기 위해 Axios 인터셉터를 활용하여 인증 처리 로직을 구현하였습니다. 물론, 사용자의 인증을 검증하는 방식은 서버 미들웨어를 통해서도 처리할 수 있습니다. 하지만 네트워크 비용 절감과 응답 속도 개선을 통해 사용자 경험을 높이기 위해, 클라이언트에서 Axios 인터셉터를 활용한 방식을 선택하였습니다.

  1. 요청 인터셉터
    요청 인터셉터는 API 요청이 서버로 전송되기 전에 실행되며, 현재 로그인한 사용자의 인증 토큰을 확인하고 이를 요청 헤더에 추가합니다. 이를 통해 모든 요청이 인증된 상태로 전송되도록 보장하였습니다.

    axiosInstance.interceptors.request.use(async (config) => {
      const user = auth.currentUser;
    
      if (user) {
        const idToken = await user.getIdToken();
        config.headers.Authorization = `Bearer ${idToken}`;
      }
      return config;
    });
  2. 응답 인터셉터
    응답 인터셉터는 서버로부터 401 (Unauthorized) 에러 응답이 반환되었을 때 실행됩니다. 이 단계에서 인증 토큰이 만료되었음을 감지하고, 새로운 토큰을 요청하여 갱신된 토큰으로 실패한 요청을 재실행하도록 구현하였습니다.

    axiosInstance.interceptors.response.use(
      (response) => response,
      async (error) => {
        const originalRequest = error.config;
    
        if (error.response?.status === 401 && !originalRequest._retry) {
          originalRequest._retry = true;
    
          const user = auth.currentUser;
    
          if (user) {
            const newIdToken = await user.getIdToken(true);
            originalRequest.headers.Authorization = `Bearer ${newIdToken}`;
    
            return axiosInstance(originalRequest);
          }
        }
    
        return Promise.reject(error);
      },
    );

(3) [결과] 사용자 인증 경험을 부드럽게 이어갑니다.

Axios 인터셉터 로직을 통해 사용자 인증을 유지하면서 새로고침하거나 페이지 이동 후에도 다시 로그인하지 않고 작업을 이어갈 수 있게 되었습니다. 이는 애플리케이션 작업에 대한 사용자의 집중도를 높일 수 있었고 반복 작업에 대한 사용자의 피로도 또한 낮출 수 있었습니다.


새로고침을 하더라도 로그인 상태 유지가 되는 상황

새로고침을 하더라도 로그인 상태 유지가 되는 상황


2-4. 잘 될거야, 낙관적 업데이트

(1) [고민] 서버의 응답을 기다려야만 하는 상황

자동 저장 기능에서 비로그인 사용자가 로그인을 하게 되면, 로컬 스토리지에 저장된 데이터를 서버로 동기화하는 작업이 진행됩니다. 이 과정에서 네트워크 속도가 느리거나 서버 응답 시간이 길어지면, 동기화가 완료될 때까지 UI 업데이트가 지연되는 문제가 발생할 수 있습니다. 이러한 상황은 사용자로 하여금 화면이 멈추거나 버벅거리는 것처럼 보이게 하여, 애플리케이션의 신뢰도를 떨어뜨릴 위험이 있습니다. 이를 해결하기 위해, 느린 네트워크 환경에서도 사용자가 불편함을 느끼지 않고 매끄럽게 애플리케이션을 사용할 수 있는 방법을 고민하게 되었습니다.


느린 서버 응답때문에 발생하는 부자연스러운 화면

느린 서버 응답때문에 발생하는 부자연스러운 화면


(2) [구현] 화면은 바꿨는데 만약 서버 요청에 실패한다면?

낙관적 업데이트는 사용자의 요청이 성공할 것이라고 가정하고, 서버 응답을 기다리지 않고 UI를 즉시 업데이트하는 방식입니다. 이 방식을 통해 사용자는 네트워크 상태와 관계없이 자신이 조작한 대로 화면이 반응하는 것을 볼 수 있어, 더 빠르고 매끄러운 사용자 경험을 제공하도록 구현하였습니다.

  • 서버 요청이 실패한다면?
    하지만, UI 업데이트 후 서버 요청이 실패하면 화면과 서버 데이터의 불일치 문제가 발생할 수 있습니다. 화면상으로는 작업이 완료된 것처럼 보이지만, 실제 서버 데이터는 업데이트되지 않아 사용자 혼란을 초래할 수 있습니다. 이러한 사용자 혼란을 덜어주기 위해 요청 실패했을 때, 아래 두 가지 방식으로 처리하였습니다.
  1. 상태 롤백: 화면과 서버 데이터 간의 불일치를 방지하기 위해 요청 전에 백업해둔 데이터를 활용하여 화면을 이전 상태로 복원하도록 구현하였습니다.
  2. 사용자 알림: 서버 요청 실패 시 적절한 알림 메시지를 표시하여 사용자에게 문제 상황을 명확히 전달합니다.

동기화 진행 중, 서버 요청이 실패한 상황

동기화 진행 중, 서버 요청이 실패한 상황


(3) [결과] 서버의 응답을 기다리지 않아도 되는 합리적인 이유

낙관적 업데이트를 진행하면서 사용자에게 매끄러운 화면 전환을 제공할 수 있었습니다. 뿐만 아니라, 요청 실패 시 롤백 처리를 구현하면서 화면과 서버 데이터의 불일치한 상황에 대한 걱정없이 낙관적 업데이트를 진행할 수 있었습니다. 이를 통해 서버의 응답을 기다리지 않고 화면을 먼저 업데이트하면서 느린 네트워크에서도 사용자가 스피커 배치 작업을 부드럽고 안정적으로 진행하도록 서비스를 제공할 수 있었습니다.


낙관적 업데이트가 적용된 동기화 과정

낙관적 업데이트가 적용된 동기화 과정



3. 프로젝트 관련 정보

3-1. 기술 스택

Client

React Vite Axios ThreeJS Zustand Styled-components

Server

NodeJS Express.js MongoDB & Mongoose

Deploy

Firebase Amazon Web Service

Test

Vitest Playwright



3-2. 프로젝트 구조

(1) 다양한 컴포넌트에서 공유되는 전역 상태

컴포넌트 간의 데이터 흐름을 간소화하고 추적을 용이하게 하기 위해, 두 개 이상의 컴포넌트에서 관리되거나 두 번 이상의 props drilling이 발생하는 상태를 Zustand 라이브러리를 사용해 전역 상태로 관리하였습니다. 특히, 3D 모델의 위치와 회전 정보는 음향 변화 뿐만 아니라 버전 저장, 회전 값 변경, 위치 변경 등 다양한 영향을 미치는 프로젝트의 핵심 상태로, 전역 상태로 관리하는 대표적인 상태입니다.

Zustand를 활용하여 3D 모델 위치, 회전 정보 전역 상태 관리 시각화

3D 모델 위치, 회전 정보 전역 상태 관리 시각화

전역 상태 관리 라이브러리로 Zustand를 사용하게 된 배경은 다음과 같습니다.

  • 경량성
    Zustand는 다른 전역 상태 관리 라이브러리에 비해 매우 작은 용량을 차지합니다. 이러한 장점은 프로젝트의 번들 크기를 감소하고 로딩 속도를 빠르게 유지할 수 있습니다.

    Zustand Mobx Redux Recoil Jotai
    0.588kb 16kb 12.7kb 23.5kb 2.5kb

    Gzip으로 최소화된 라이브러리 압축 용량

  • 효율성
    Zustand는 다른 라이브러리에 비해 설정이 간단하고 보일러 플레이트 코드가 짧기 때문에 상태 관리 로직의 가독성을 향상시킬 수 있습니다. 또한 발행-구독(Pub-Sub) 모델을 활용하기 때문에 구독 단위의 효율적인 상태 관리가 가능합니다.

이를 통해, Zustand로 복잡한 상태 관리 코드의 비효율성을 줄이고 컴포넌트 간 데이터 흐름을 단순화하여 코드의 가독성을 크게 향상 시킬 수 있었습니다. 또한, 상태 변경의 영향을 명확하게 추적할 수 있어 기능 추가나 디버깅 과정에서도 작업 효율성을 높일 수 있었습니다.


(2) 중앙 집중화된 상수 데이터 파일

코드의 가독성과 재사용성을 향상시키기 위해, 중앙 집중화된 단일 파일에서 관리하도록 구현하였습니다. 해당 파일에는 프로젝트에서 사용되는 고정된 값이나, 전역적으로 참조되는 데이터를 관리하도록 설계하였습니다.

  📁 constants.ts

  const AUTO_SAVE_DELAY = 5000;
  const SPEAKER_SIZE = 0.5;
  const LISTENER_SIZE = 1;
  const ROOM_SIZE = 30;

또한 직관적인 상수 이름을 사용하여 코드의 가독성을 높일 수 있었습니다.

  • 하드코딩된 숫자 사용
      {
        position: new Vector3(-(30 / 2), 5 / 2, 0),
        rotation: new Euler([0, -Math.PI / 2, 0]),
      },

숫자 데이터가 무엇을 의미하는지 코드만으로는 파악하기 어렵습니다. 이러한 경우 유지보수를 진행할 때, 이 값이 다른 곳에서 어떻게 사용되는지 추적하기 힘들고 변경할 때, 모든 코드를 수정해야 할 수 있습니다.

  • 상수화된 데이터 사용
      {
        position: new Vector3(-(ROOM_SIZE / 2), WALL_HEIGHT / 2, 0),
        rotation: new Euler(...ROTATE_Y_90_DEGREES),
      },

직관적인 이름을 가진 상수화된 데이터를 통해 값의 의미를 이전보다 명확하게 파악할 수 있었습니다. 이를 통해, 상수 값의 재사용성일관성을 강화하고 관리 편의성을 제공하므로써 하드코딩으로 인한 버그 발생 가능성을 줄일 수 있었습니다.


(3) 코드의 중복을 줄이는 공통 컴포넌트

  🗂️ common
    📁 Button.tsx
    📁 Icon.tsx
    📁 Modal.tsx
    📁 Model.tsx
    📁 NavHeader.tsx

코드 중복을 줄이고 유지보수성을 높이기 위해, UI에서 공통적으로 사용하는 컴포넌트를 구현하였습니다. 이러한 공통 컴포넌트는 state와 props를 활용하여 다양한 상황에 맞게 유연하게 동작하도록 설계하였습니다.

  • 예시) 버튼 컴포넌트

    const Button = ({ text, handleClick }) => {
      return <button onClick={handleClick}>{text}</button>;
    };

이를 통해, 클릭할 때 각기 다른 기능을 수행하는 버튼을 여러 컴포넌트로 나눌 필요 없이, 하나의 컴포넌트에 state와 props를 활용해 다양한 버튼을 구현할 수 있었습니다.


(4) 관심사 분리를 적용한 커스텀 훅

코드의 재사용성을 향상시키고 파일명만 보더라도 어떠한 기능을 수행하는 파일인지 명료하게 나타나기 위해 관심사 분리 원칙을 적용하여 기능별로 커스텀 훅을 구현하였습니다.

  🗂️ hooks
    📁 useAutoSaveVersion.tsx
    📁 useDraggableTarget.tsx
    📁 useRotatableTarget.tsx
    📁 useUpdateData.tsx
   .
   .
   .
  • useAutoSaveVersion.tsx: 버전을 자동 저장하는 기능
  • useDraggableTarget.tsx: 3D 모델을 드래그하는 기능
  • useRotatableTarget.tsx: 3D 모델의 회전을 바꾸는 기능
  • useUpdateData.tsx: 데이터를 동기화하는 기능


4. 구현하며 배운 점들

(1) 3D 구현 도전

3D와 관련된 기능들을 구현하기 위해 수학적 개념에 대한 이해가 필요했습니다. 특히 3D 모델의 움직임과 회전 변환을 구현하기 위해 벡터에 대한 개념도 이해해야 했습니다. 관련 전공자가 아니다 보니 매우 어려웠지만, 평소 관심있었던 주제를 구현하기 위해 도움이 될 만한 개념이라 배워가는 과정이 모두 흥미로웠습니다.

(2) 소리 변화 구현

평소 음악과 스피커에 관심이 많다고 생각했던 스스로를 돌아볼 수 있는 시간이었습니다. 생각보다 복잡했던 소리의 전달 방식을 완벽하게 구현하지 못했지만 사용자들에게 의미 있는 경험을 제공하기 위해 방식을 고민하고 설계하는 과정은 매우 재미있었습니다.

(3) 사용자 중심 설계

프로젝트를 진행하면서 기술적 구현 못지 않게 사용자 경험이 매우 중요하다는 것을 깨달았습니다. 다양한 사용자 그룹을 대상으로 테스트하면서 그 피드백을 기반으로 인터페이스와 기능들을 수정했습니다. 이러한 과정을 통해 사용자 경험을 향상시킬 수 있는 기능 구현과 이를 위한 기획 단계의 중요성을 느낄 수 있었습니다.

About

사용자가 스피커의 배치를 조정하면서 공간 음향의 효과를 경험할 수 있는 가상 시뮬레이터입니다.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published