본문 바로가기

Issues/frontend

(성능 최적화) 병원 진료용 저사양 PC 캡쳐 이미지 목록 렌더링 최적화

 

이 글에서는 오래된 병원의 저사양 PC에서 발생한 성능 문제를 어떻게 접근하고 최적화했는지, 그리고 그 과정에서 얻은 기술적 통찰과 교훈을 공유하고자 합니다.

 

1. 오래된 병원의 저사양 PC 문제

최근 당사와 연계된 병원 한 곳의 시스템을 점검할 기회가 있었습니다. 특히 오래된 병원에서는 IT 인프라가 낙후되어, 성능이 매우 낮은 PC를 여전히 사용하는 경우가 많았습니다. 이런 저사양 환경은 우리가 제공하는 촬영 모듈의 성능에 직접적인 영향을 주었고, 사용성에도 큰 지장을 초래하고 있었습니다.

 

### 병원 PC 스펙 현황

PC OS CPU RAM 실 RAM bit
4번 체어 윈도우 10 Home 인텔 펜티엄(R) 4.10GHz, 2코어, 4논리 4GB 1.18GB 64bit
5번 체어 윈도우 7 Home 인텔 코어(R) 3.3GHz, 2코어, 4논리 4GB 395MB 32bit
6번 체어 윈도우 7 Home 펜티엄(R) 듀얼 코어 3.2GHz, 2코어, 2논리 2GB 1.02GB 32bit
7번 체어 윈도우 7 Home 인텔 코어(TM) 2.8GHz, 4코어, 4논리 2GB 669MB 64bit
8번 체어 윈도우 11 Home 인텔(R) 코어 3.0GHz, 4코어, 8논리 8GB 2.64GB 64bit
4번 진료 윈도우 11 Home 인텔 펜티엄(R) 1.5GHz, 4코어, 4논리 4GB 747MB  
5번 진료 윈도우 11 Pro 인텔 펜티엄(R) 2.0GHz, 2코어, 4논리 8GB 3.62GB  
6번 진료 윈도우 10 Home 인텔 펜티엄(R) 1.60GHz, 4코어, 4논리 8GB 4.78GB  
7번 진료 윈도우 11 Home 인텔 셀레론(R) 1.1GHz, 4코어, 4논리 4GB 1.14GB  

 

이처럼 진료 현장에서 PC 성능이 부족하다 보니, 촬영 모듈 사용 시 CPU와 메모리 사용량이 한계에 도달하는 사례가 빈번했습니다. 특히 구강 카메라로 촬영을 하면서 이미지 목록이 업데이트되는 과정에서 심각한 성능 저하가 발생했습니다.

 

2. 모노레포 도입과 성능 저하 문제

이전에 모노레포로 전환하기 전에는 웹 버전과 일렉트론으로 구현된 데스크탑 버전 모두에서 성능 문제는 거의 발생하지 않았습니다. 하지만 제품의 다양한 버전들을 일관되게 관리하기 위해 모노레포로 전환하면서 문제가 발생했습니다. 특히, 구강 카메라로 이미지를 촬영하고 그 데이터를 관리하는 촬영 모듈에서 성능 저하가 명확하게 나타나기 시작했습니다.

 

당시 병원에서 환자를 촬영하는 동안 CPU 사용량이 한계치를 넘어서는 것을 확인할 수 있었습니다. 구강 카메라로 촬영을 하는 경우, 총 6번의 촬영 시도가 이루어지고 이로 인해 12장의 이미지가 생성되어야 했습니다. 하지만 촬영 후 이미지 목록에 추가되는 과정에서 리렌더링이 발생하면서 브라우저의 성능이 점점 나빠졌고, 결국 촬영 작업이 원활하게 진행되지 않는 상황까지 이어졌습니다. 이는 우리가 촬영 모듈의 성능을 개선해야 할 필요성을 절실히 느낀 계기였습니다.

 

이론적으로는 모노레포를 도입한다고 해서 PC나 브라우저 리소스를 더 많이 소모하지는 않지만, 실제로 성능 저하가 발생한 것입니다. 촬영 모듈을 사용하면서 개발자 도구의 Performance 탭이나 윈도우의 활성 상태 CPU 사용량 등을 통해 분석을 시도하였지만, 모노레포 도입 이전과 비교하여 어떤 부분이 리소스를 더 많이 소모하는지 명확한 원인을 밝혀내기 어려운 상황이었습니다.

 

3. 첫 번째 구현: 빠른 개발과 그 한계

참고 1 : 기존 코드 보기 링크 참고 2 : 개선 코드 보기 링크

 

당시에는 시간적인 여유가 없어서 빠르게 촬영 모듈을 구현하는 데 집중했습니다. 촬영 기능이 잘 동작하는 것에 중점을 두었고, 코드에서 메모이제이션이나 메모리 효율화 같은 성능 최적화의 관점을 전반적으로 반영하지 못했습니다. 하지만 시간이 지나면서 저사양 환경에서의 성능 문제는 점차 뚜렷해졌고, 성능을 개선하지 않으면 제품의 사용성을 크게 저하시킬 수밖에 없는 상황에 처했습니다.

첫 번째로 작성한 코드는 정말 빠르게 구현된 것이었습니다. 이미지를 촬영하고 `capturedImages`라는 배열에 추가할 때마다 전체 배열을 다시 할당하고 새로운 `arrayImages` 배열을 설정했습니다. 이로 인해 매번 전체 이미지 리스트를 새로 복사하고 역순으로 정렬해야 하는 상황이 발생했습니다. 결국, 이미지가 계속 추가될 때마다 리렌더링 비용이 기하급수적으로 증가하는 결과를 낳았습니다.

useEffect(() => {
    if (capturedImages.length === 0) setPrevIndex(null);
    setArrayImages(capturedImages); // 성능 문제 발생
}, [capturedImages]);

 

전체적인 코드 개선 방향성

촬영 모듈의 성능을 최적화하기 위해 기존의 전체 리스트 업데이트 방식을 부분 업데이트 방식으로 전환하는 것을 중심으로 개선을 시도했습니다. 이를 통해, 이미지 캡처, 업로드, 그리고 사용자 이벤트 처리 프로세스를 더 효율적으로 개선할 수 있었습니다.

첫 번째 코드는 빠른 구현을 중시해 성능 최적화를 고려하지 않은 부분이 많았지만, 두 번째 개선된 코드에서는 각 과정에서 발생하는 불필요한 전체 복사, 반복 연산, 그리고 리렌더링을 최소화하도록 하였습니다. 

 

 

4. 성능 최적화 방안

촬영 모듈에서 성능 문제를 해결하기 위해 이미지 상태를 효율적으로 관리하도록 코드를 수정했습니다. 핵심은 새로 추가된 이미지만을 처리하는 것입니다.

 

4.1. 새로운 상태 변수 newImages 추가로 이미지 상태 관리 개선

기존 코드에서는 전체 이미지 리스트를 반복적으로 업데이트하여 많은 이미지가 존재할 경우 성능 저하가 발생했습니다. 개선된 코드는 새롭게 추가된 이미지만 처리하도록 하여 성능 문제를 개선합니다.

 

## 기존 코드의 문제점

  1. 리렌더링 문제: useEffect가 capturedImages가 변경될 때마다 전체 이미지 리스트를 역순으로 변환하고 상태로 업데이트했습니다. 이는 리스트의 크기가 커질수록 성능에 영향을 미쳤습니다.
  2. 성능 문제: 이미지가 추가될 때마다 전체 이미지 배열(arrayImages)을 변환하고 상태로 업데이트하는 방식은 불필요한 반복 작업이었습니다. 특히 convertBase64ToBlob 같은 연산이 촬영 시점에 발생하여, 즉각적인 UI 반응 속도를 느리게 만들었습니다.

## 기존 코드 : 기존 코드에서는 모든 이미지를 capturedImages에서 관리하며 전체 리스트를 반복적으로 처리했습니다.

const [arrayImages, setArrayImages] = useState<Array<CamproImage | PencImage>>([]);
useEffect(() => {
    if (capturedImages.length === 0) setPrevIndex(null);
    setArrayImages(capturedImages);
}, [capturedImages]);

 

## 개선 코드 : 새롭게 추가된 이미지만 관리하는 newImages 상태를 도입하여, 추가된 이미지에 대해서만 처리하도록 변경했습니다.

const [newImages, setNewImages] = useState<Array<CamproImage | PencImage>>([]);

useEffect(() => {
    if (capturedImages.length === 0) {
        setNewImages([]);
        setPrevIndex(null);
        setCamproImage([]);
        setPencImage([]);
    } else if (capturedImages.length > arrayImages.length) {
        const newImages = capturedImages.slice(arrayImages.length);
        setNewImages(newImages);
    } else {
        setNewImages([]);
    }
    setArrayImages(capturedImages);
}, [capturedImages, arrayImages]);

 

## 개선 포인트

  1. newImages 상태 추가: 새롭게 추가된 이미지만 관리하는 newImages 상태를 도입함으로써, 전체 이미지 배열을 다시 처리할 필요 없이 추가된 이미지에 대해서만 효율적으로 처리할 수 있습니다.
  2. 불필요한 전체 갱신 제거: 전체 리스트를 반복 갱신하는 대신, 새로 추가된 부분만 업데이트함으로써 useEffect의 부하를 줄였습니다. 이를 통해 성능 저하 문제와 UI 반응 지연을 완화할 수 있었습니다.
  3. 코드 간소화 및 명확성 증가: 상태가 명확하게 구분되면서 로직이 더 직관적으로 변경되었습니다. 이제 전체 이미지 배열(arrayImages)과 새롭게 추가된 이미지(newImages)의 역할이 분명히 나누어졌습니다.

 

4.2. 이미지 처리 로직 최적화

기존 코드에서는 전체 이미지 리스트를 반복적으로 업데이트하면서 성능 저하가 발생했습니다. 개선된 코드는 새로 추가된 이미지에 대해서만 변환 및 상태 업데이트 작업을 수행하여 최적화했습니다.

 

## 기존 코드 : 기존 코드에서는 모든 이미지를 반복적으로 변환하고 상태를 갱신했기 때문에 비효율적이었습니다.

useEffect(() => {
    const processImages = async () => {
        const newCamproImagesPromises = arrayImages.filter((image) => image.camera === "QRAYCAM")
            .map(async ({ imgSrc, id, bodyPart, capturedAt }) => {
                const blob = await convertBase64ToBlob(imgSrc);
                return { image: new File([blob], `${id}_${bodyPart}.jpg`, { type: "image/jpeg" }), capturedAt, patientId: id, bodyPart };
            });
        const newPencImagePromises = arrayImages.filter((image) => image.camera === "QRAYPEN")
            .map(async ({ imgSrc, capturedAt, id, teethNo, toothSurface }) => {
                const blob = await convertBase64ToBlob(imgSrc);
                return { image: new File([blob], `${id}_${teethNo}.jpg`, { type: "image/jpeg" }), capturedAt, patientId: id, teethNo, toothSurface };
            });
        const [newCamproImages, newPencImages] = await Promise.all([Promise.all(newCamproImagesPromises), Promise.all(newPencImagePromises)]);
        setCamproImage(newCamproImages);
        setPencImage(newPencImages);
        setReversedPhotos([...arrayImages].reverse());
    };
    processImages();
}, [arrayImages]);

 

## 개선 코드 : 새롭게 추가된 이미지에 대해서만 파일 변환 및 상태 업데이트 작업을 수행합니다. 기존 이미지는 유지하면서 새로운 이미지만 추가하는 방식으로 최적화했습니다.

useEffect(() => {
    const processImages = async () => {
        const newCamproImagesPromises = newImages.filter((image) => image.camera === "QRAYCAM")
            .map(async ({ imgSrc, id, bodyPart, capturedAt }) => {
                const blob = await convertBase64ToBlob(imgSrc);
                return { image: new File([blob], `${id}_${bodyPart}.jpg`, { type: "image/jpeg" }), capturedAt, patientId: id, bodyPart };
            });
        const newPencImagePromises = newImages.filter((image) => image.camera === "QRAYPEN")
            .map(async ({ imgSrc, capturedAt, id, teethNo, toothSurface }) => {
                const blob = await convertBase64ToBlob(imgSrc);
                return { image: new File([blob], `${id}_${teethNo}.jpg`, { type: "image/jpeg" }), capturedAt, patientId: id, teethNo, toothSurface };
            });
        const [newCamproImages, newPencImages] = await Promise.all([Promise.all(newCamproImagesPromises), Promise.all(newPencImagePromises)]);
        setCamproImage((prev) => [...prev, ...newCamproImages]);
        setPencImage((prev) => [...prev, ...newPencImages]);
        setReversedPhotos([...capturedImages].reverse());
    };
    processImages();
}, [newImages]);

 

## 개선 포인트

  1. 효율적인 상태 관리: 새롭게 추가된 이미지에 대해서만 처리함으로써, 전체 이미지를 반복적으로 갱신하는 비효율성을 제거했습니다.
  2. 비동기 처리의 최적화: 새로 추가된 이미지에 대한 비동기 처리를 효율적으로 수행하여, 변환 작업의 부하를 줄이고 즉각적인 UI 반응을 개선했습니다.
  3. 상태 업데이트 방식 변경: 기존 이미지는 유지하고 새로운 이미지만 추가하는 방식으로 setCamproImage와 setPencImage를 사용해 성능을 최적화했습니다.

 

4.3. 이미지 렌더링 로직 최적화

기존 코드에서는 `ViewerImageItem`을 **index**를 기준으로 렌더링했기 때문에 리스트가 변경될 때마다 React가 불필요하게 렌더링을 반복하게 되어 성능 저하가 발생할 수 있었습니다. 개선 코드에서는 각 이미지에 **고유 식별자**를 설정하여, 효율적으로 업데이트하도록 했습니다.

 

## 기존 코드 : 기존 코드에서는 모든 이미지를 반복적으로 변환하고 상태를 갱신했기 때문에 비효율적이었습니다.

const renderImageItem = () =>
    reversedPhotos.map((photo, index) => (
        <ViewerImageItem
            key={index}
            photo={photo}
            index={index}
            ...
        />
    ));

 

## 개선 코드 : 각 이미지의 고유 식별자를 사용하여 key를 설정함으로써, React가 효율적으로 업데이트를 관리할 수 있도록 했습니다.

const renderImageItem = () =>
    reversedPhotos.map((photo, index) => (
        <ViewerImageItem
            key={`${photo.id}-${photo.time}`}
            photo={photo}
            index={index}
            ...
        />
    ));

 

## 개선 포인트

  1. 고유 식별자 사용: photo.id와 photo.time을 조합하여 각 이미지에 대해 고유한 key를 설정했습니다. 이를 통해 React가 리스트를 효율적으로 업데이트하고 불필요한 렌더링을 줄일 수 있습니다.

 

4.4. 이벤트 핸들러 최적화

기존 코드에서는 showTeethNumOrBodypartByCameraType 함수가 일반 함수로 정의되어 있어, 렌더링 시마다 함수가 재생성되었습니다. 개선된 코드에서는 **useCallback**을 사용하여 함수가 메모이제이션되도록 변경했습니다.

 

## 기존 코드

const showTeethNumOrBodypartByCameraType = (e: MouseEvent<HTMLDivElement>, index: number, cameraType: string) => {
    ...
};

 

## 개선 코드 : useCallback을 사용하여 함수가 불필요하게 재생성되는 것을 방지하고, 동일한 의존성을 가지는 경우 기존의 함수를 재사용할 수 있도록 했습니다.

const showTeethNumOrBodypartByCameraType = useCallback(
    (e: MouseEvent<HTMLDivElement>, index: number, cameraType: string) => {
        ...
    },
    [reversedPhotos, prevToothImageIndex, isImageClicked, isShowedBodyPart, prevClickedImageIndex],
);

 

 

## 개선 포인트

  1. 메모이제이션 적용: useCallback을 사용하여 동일한 의존성을 가진 경우 함수가 재사용되도록 최적화했습니다. 이는 특히 많은 이미지가 렌더링될 때 성능을 개선하는 데 도움을 줍니다.

 

4.5. 이미지 상태 관리 및 최적화

기존 코드에서는 capturedImages가 변경될 때마다 전체 이미지를 처리하여 reversedPhotos를 설정했습니다. 개선 코드에서는 새로 추가된 이미지삭제된 이미지에 대한 상태만 갱신하도록 설정하여, 불필요한 업데이트를 줄였습니다.

 

## 기존 코드

useEffect(() => {
    if (listRef.current && reversedPhotos.length > prevCount) {
        listRef.current.scrollTop = 0;
    }
    setPrevCount(reversedPhotos.length);
}, [reversedPhotos]);

 

## 개선 코드 : 이미지가 추가되거나 삭제될 때만 reversedPhotos 상태를 갱신하여, 전체 이미지 리스트에 대한 업데이트를 최소화했습니다.

useEffect(() => {
    if (listRef.current && reversedPhotos.length > prevCount) {
        listRef.current.scrollTop = 0;
    }
    setPrevCount(capturedImages.length);
}, [reversedPhotos]);

 

## 개선 포인트

  1. 불필요한 업데이트 최소화: 이미지가 추가되거나 삭제될 때만 상태를 갱신하도록 하여 전체 리스트에 대한 불필요한 업데이트를 줄였습니다. 이를 통해 성능이 개선되고, 렌더링 효율이 높아졌습니다.

 

5. 성능 최적화의 성과와 교훈

개선 작업 이후, 저사양 PC 환경에서도 촬영 모듈의 성능이 크게 개선되었습니다. 전체 리스트를 반복적으로 갱신하는 대신 필요한 부분만 업데이트하는 방식은 단순하지만, 저사양 환경에서는 큰 차이를 만들어냈습니다. 특히, 메모리 효율화와 불필요한 렌더링 방지로 인해 촬영 모듈의 반응성이 크게 향상되었습니다.

 

이번 최적화 작업을 통해 얻은 가장 큰 교훈은 **"기본에 충실한 최적화"**가 중요하다는 점이었습니다. 초기에는 빠른 구현에 집중하면서 성능 최적화를 간과했지만, 사용자 환경을 고려한 성능 개선이 얼마나 중요한지 다시 한번 깨닫게 되었습니다. 저사양 환경에서도 잘 동작하는 소프트웨어를 만드는 것은, 사용자의 경험을 최우선으로 고려하는 개발자의 기본적인 책임이라는 점을 명확히 인지하게 되었습니다.

 

기술적으로는 단순한 최적화 작업처럼 보일 수 있지만, 이러한 작은 변화들이 실제 현장에서 큰 영향을 미친다는 점을 항상 염두에 두고 앞으로도 개발에 임하려 합니다.

 

또한, 사용자의 PC 사양이나 인터넷 환경 등을 고려한 사용자 중심의 개발이 중요하다는 점도 다시 한번 느꼈습니다. 개발자는 단순히 기능을 구현하는 것에 그치지 않고, 다양한 사용 환경에서도 일관된 사용자 경험을 제공할 수 있도록 신경 써야 합니다. 이를 위해 앞으로도 사용자의 상황을 이해하고, 그에 맞는 솔루션을 제공하는 데 집중하려 합니다.