개발자 윤찬

웹 개발자 윤찬의 프로필 사진개발자 윤찬
2025. 8. 23.

라이브러리 커스터마이징(Osd)

VienceReact.jsOpen Source
"인터랙티브하게"

대용량 이미지를 다룬다는 것

예전 대용량 이미지 업로드 부분에서, 한번 언급하고 나간적이 있다. 병리 데이터 같은 큰 이미지를 다루기 위해서 svs같은 확장자를 사용한다. 이때 브라우저에 큰 화면을 한번에 보여줄 수 없으니, 보통 OpenSeadragon 같은 라이브러리를 활용해 타일 단위로 나누어 화면에 렌더링한다.


OpenSeaDragon

구글 지도를 생각하면 편한데, 세계 지도를 한 장으로 불러오는 게 아니라, 내가 보는 지역(서울 강동구 등)만 불러오고, 확대하면 더 자세한 타일을 다시 가져오는 원리이다. 그래서 병리학에서 많이 쓰고 있으며

1단계(최소 확대) → 전체 이미지를 작은 크기로 만든 1장
2단계(중간 확대) → 이미지를 4개 타일로 나눔
3단계(더 확대) → 16개 타일로 나눔

이처럼 단계별로 타일이 준비되어 있으며, 한 번 불러온 타일은 클라이언트 측에 캐싱되기 때문에 이후 같은 영역을 다시 탐색할 때 빠르게 로딩된다. 결국 이 구조 덕분에 수십 GB에 달하는 초대형 이미지도 브라우저 환경에서 부드럽게 확대/축소하며 탐색할 수 있게 된다. 특히 병리학에서는 슬라이드 한 장이 워낙 크기 때문에, 연구자나 의사가 특정 부위만 빠르게 확인할 수 있는 효율적인 뷰어 환경을 제공할 수 있다.

OSD는 필수 인터렉션이 있는데, 이동이다.

줌(Zoom) 마우스 휠이나 터치 제스처를 통해 현미경처럼 이미지를 확대·축소할 수 있다. 최소 확대에서는 전체 구조를 조망할 수 있고, 최대 확대에서는 세포 단위까지 깊이 들어가 관찰할 수 있다.

이동(Pan) 확대된 상태에서 이미지를 끌어 움직이며 원하는 영역으로 빠르게 이동할 수 있다. 마치 현미경 스테이지를 움직이며 슬라이드를 탐색하는 것과 유사하다.

이런 기본 요소를 가지고, 회사서비스중 하나인 Smart Patho Viewer를 만들기 위해 OSD를 커스텀한 과정에 대한 글이다.


요구사항

기존의 OSD가 단일 대형 이미지를 띄워 보여주는 방식이라면, 최종 요구사항은 조금 더 복잡하다. 우선, 여러 장의 선택된 이미지가 동시에 열리며 이동과 확대/축소가 서로 동기화되어야 한다. 사용자가 한 이미지를 이동하거나 줌인·줌아웃하면, 다른 이미지들도 동일한 위치와 배율로 함께 반응하는 것이다. 또한 그 위에는 체크 형태의 Annotation 기능이 제공되어야 하며, 사용자가 특정 지점을 표시하거나 기록할 수 있어야 한다.

여기에 더해, 사용자가 실제로 이동하며 관찰했던 영역을 기반으로, 줌 레벨에 따른 Heatmap을 시각적으로 표시해 주는 기능도 필요하다. 이를 통해 어느 영역을 자주 확대해 봤는지, 어떤 부분을 집중적으로 탐색했는지 한눈에 파악할 수 있다.

즉, 단순히 이미지를 보여주는 수준을 넘어,

  • 동기화된 멀티뷰어
  • 상호작용 가능한 주석(Annotation)
  • 사용자 탐색 패턴을 반영한 Heatmap

까지 아우르는 고도화된 뷰어가 최종적으로 요구되는 것이다. 결론적으로, 이러한 기능을 구현하기 위해서는 단순한 뷰어 수준을 넘어, 관련 알고리즘 개발이 함께 이루어져야 하며, 이를 효과적으로 전달할 수 있는 시각화 기술 역시 필수이다.


커스터 마이징

Annotation 기능은 D3.js를 활용하여 구현하였다. OSD는 한 번 렌더링된 이후에 리액트 의존성 값이 갱신되면 줌이나 이동 영역 좌표가 초기화되는 문제가 있었다. 따라서 Annotation은 리액트 상태 관리와는 분리하여, 별도의 overlay 레이어 위에 올려서 처리해야 했다. 삭제나 편집이 필요할 경우에는 해당 overlay 요소를 직접 찾아가 조작하는 방식으로 구현하였다.

왜 D3와 overlay 방식이 적합했는가?

Annotation은 점, 선, 사각형 등 벡터 요소가 많기 때문에, 캔버스보다 D3의 SVG 기반 접근이 훨씬 직관적이다. 이들을 분리해서, OSD는 이미지 렌더링과 뷰포트 동기화에 집중하고, D3는 그 위에 독립된 레이어로 Annotation만 담당하므로 역할이 명확히 분리된다.

또한 OSD는 최초 한 번만 마운트한다. 즉, 이미지 뷰어는 한 번만 생성하고 이후에는 언마운트하지 않는다. 이렇게 해야 줌/이동 상태가 매번 초기화되는 문제를 피할 수 있다.

실제 환자데이터를 올릴 수 없어서 AI로 이미지 생성

이때 Annotation 데이터가 변경될 때마다, 리액트 상태로 OSD 전체를 다시 렌더링하지 않고, D3 Selection을 통해 기존 SVG 요소를 직접 업데이트한다. 예시로는

  • 점을 추가할 경우 → d3.select("svg").append("circle") 형태로 즉시 반영
  • 편집할 경우 → 기존 요소를 select로 찾아 attr 값(좌표, 크기 등)을 갱신

또한 OSD에서 줌/이동 이벤트가 발생할 때마다 callback을 받아, 해당 뷰포트 좌표계를 D3 Overlay 좌표로 변환해 반영하였다.

즉, OSD는 한 번 마운트 후 뷰어 상태를 유지하고, Annotation은 D3를 통해 Overlay에서 실시간으로 그려지고 업데이트되는 구조로 문제를 해결했다.


히트맵의 경우에는, 뷰어에서 사용자가 어떤 영역을 많이 관찰했는지 시각화 하는 것인데, 이를 Navigator위에 색을 표시하는 것이다.(줌에 따라서 농도가 다름)

히트맵용 캔버스를 먼저 초기화

heatmapCanvasRef와 heatmapContextRef를 이용해 Navigator 영역 위에 캔버스를 올린다.


뷰포트 좌표를 Navigator 좌표로 변환

OSD에서 관찰한 영역은 뷰포트 좌표로 저장되지만, Navigator 위에 그릴 때는 픽셀 좌표로 변환하는데, Navigator 크기와 이미지 크기 비율을 계산해서 반환한다.


색상 결정 및 이동에 따라 좌표를 기준으로 히트맵 적용

이후 줌 레벨별 색상을 결정하고, 타원으로 히트맵 영역을 그리며 사용자가 이동하거나 줌할 때마다 현재 뷰포트 영역과 줌 레벨을 기록한다. 이때 중복 영역은 기록하지 않고, 새로운 영역만 히트맵에 추가하는 방식으로 구현하였다.

- Overlay 캔버스를 별도로 두어 OSD 이미지 렌더링과 독립적으로 히트맵을 그림.
- 뷰포트 → Navigator 좌표 변환으로 정확한 위치에 색상 표시.
- 줌 레벨별 색상 변화로 자주 보는 영역을 강조.
- 실시간 업데이트: 사용자가 이동·줌할 때마다 즉시 히트맵 반영.

실제 서비스가 아닌, 인터넷 상의 공유된 이미지와, ppt로 만듦

정리하면, 위와 같고, osd상에서 이동한 영역을 볼 때 동시에 Navigator에도 현재의 zoom에 따라서 아래와 같이 히트맵을 남길 수 있었다.


마지막은 동기화 관련한 것인데, 이는 저장할 데이터 양식을 우선 정해야했다. 최상단 컴포넌트에서 현재 화면에 있는 VIEWER들의 정보를 모두 개별로 관리하고 있으며, 바로 하위 컴포넌트(OSD VIEWER)에게 전달을 하는 방식이다. 예를들어 다음과 같이 관리할 데이터가 있다면

interface ViewerState {
  id: string;                 // VIEWER 고유 ID
  zoom: number;               // 현재 줌 레벨
  center: { x: number; y: number }; // 중심 좌표
  rotation: number;           // 회전 각도
  overlays: OverlayState[];   // 히트맵, 마커 등 추가 레이어
}

각 VIEWER에서 발생하는 이벤트를 다음과 같이 정의한다.

  • 뷰 상태 이벤트: 줌, 이동, 회전 등 화면에 영향을 주는 이벤트
  • 레이어 이벤트: 히트맵 표시, 마커 추가/삭제 등
  • 사용자 상호작용 이벤트: 클릭, 드래그, 선택

그리고, 위의 이벤트가 발생을 한다면 상위 컴포넌트의 상태를 업데이트하고, 필요 시 다른 VIEWER와 동기화하는 방식으로 구현했다. 이때 고민되었던 점이, 동기화를 업데이트하는 로직과, 이전의 정보를 불러오는 로직의 파이프라인 순서였다.

예를 들어, 사용자가 화면을 이동하거나 줌을 변경하면, 먼저 현재 상태를 상위 컴포넌트에서 업데이트하고, 그 이후 다른 VIEWER에게 동기화 이벤트를 전달해야 한다. 만약 이전 정보를 불러오는 로직과 순서가 꼬이면, 화면이 잠시 잘못 표시되거나 히트맵과 마커가 어긋나는 문제가 발생할 수 있다. 이를 해결하기 위해 다음과 같은 순서를 적용했는데, 전체 흐름은

사용자 이벤트뷰어 상태 기록중앙 관리다른 뷰어 동기화히트맵/마커 복원

순으로 적용시켰다.

위의 흐름으로 상위 컴포넌트가 모든 Viewer의 정보를 중앙관리 할 수 있도록 처리했으며, 2개의 Viewer가 있다고 가정했을때 이를 비동기적으로 처리할 수 있었다.

만약 이전의 작업을 하다가 다시, 불러와야할 때는, 동기화 요소들중 제일 첫번째 요소의 viewer State를 기준으로, 동기화된 것들끼리 sync가 맞춰지도록 적용시켰다.

또한 이동과 줌 이벤트에 따라서 각각의 사진들이 동기화 될때도 수식이 필요했는데 각 뷰어마다 기본 줌 레벨이 다르기 때문에, 단순히 Pan과 Zoom 값을 전달하는 것만으로는 정확한 위치 동기화가 어렵다. 이를 해결하기 위해, Pan 이벤트가 발생할 때는 뷰어의 현재 줌 레벨을 고려한 보정(scale) 계산을 수행했다.

예를 들어, 코드에서 사용한 방식은 다음과 같다.

const sourceZoom = syncData.panDelta.currentZoom || 1;
const currentZoom = viewer.viewport.getZoom();
const scale = sourceZoom / currentZoom;

const newCenter = new OpenSeadragon.Point(
  baseCenter.current.x + syncData.panDelta.deltaX * scale,
  baseCenter.current.y + syncData.panDelta.deltaY * scale,
);
  • sourceZoom은 동기화 이벤트를 발생시킨 기준 뷰어의 줌 레벨
  • currentZoom은 현재 뷰어의 실제 viewport 줌 레벨

scale = sourceZoom / currentZoom를 통해, 다른 뷰어에서 동일한 이동 거리를 화면 비율에 맞춰 보정

최종적으로 newCenter를 계산하여, 각 뷰어의 viewport 중심(center)을 정확하게 이동 시킬 수 있었다.

즉, 같은 물체를 찍은 사진이라도 각 뷰어가 가지고 있는 기본 줌 레벨이 다르더라도, viewport 중심과 줌 비율을 계산하여 화면 상 크기와 위치를 일치시키는 방식이다.

이 과정 덕분에, 사용자가 한 뷰어에서 이미지를 이동하거나 확대/축소하면, 다른 동기화된 뷰어들도 정확히 동일한 물리적 위치와 크기로 화면에 표시될 수 있다.

마무리로, 위의 커스텀 과정이 모두 적용된 결과는 다음과 같은 서비스를 완성시킬 수 있었다.

Profile Picture

CHAN

과정은 복잡하되, 결과는 단순하게

Thank You for Visiting My Blog, Have a Good Day 😆
ⓒYoonchan Cho