Next.jscornerstone.jsDICOM의료이미지

TIL - "cornerstone.js"로 의료이미지 다루기

September 07, 2025

thumbnail

배경

팀 프로젝트를 하나 새로 진행하면서 새로운 도메인 영역인 의료•헬스케어 주제를 시도했다. 복부 쪽 x-ray 사진을 이전 개인 프로젝트에서 다뤘던 팀원이 있었던 덕분이다.

의료이미지 혹은 영상 쪽을 더 딥하게 다루고 싶었던 팀원의 제안으로 DICOM 파일 포맷으로 제공되는 의료이미지를 다뤄보게 되었다.

DICOM(Digital Imaging and Communications in Medicine) 이란?

의료 영상(CT, MRI, X-ray, 초음파 등)을 저장, 전송, 표준화하기 위한 국제 표준. 의료 데이터와 환자, 질환 등의 메타데이터가 합쳐진 파일의 형태다.

현장에서 프론트엔드 작업을 많이 맡아왔었던 나는 이번 프로젝트에서도 프론트엔드 작업을 맡게 되었다. DICOM 포맷 파일을 다루기 위해서 추천되는 라이브러리를 리서치 해봤는데, cornerstone.js 가 추천되었다. 이미지를 렌더링하는 것 외에도, 이미지와 상호작용 하는 여러 툴들을 쉽게 사용할 수 있는 API를 제공해준다. 하지만 이 라이브러리의 사용은 도메인에만 국한되어 있어서 참고자료가 적다는 문제가 있었다. 요근래 개발자 생산성을 크게 도와주고 있는 LLM도 cornerstone.js의 적용에는 효과가 별로 없었다.

참고자료가 적다는 문제만으로도 사용 부적격 판단을 낼 수 있지만, cornerstone.js는 그러기엔 너무 매력적이었다. cornerstone.js를 기반으로 잘 작동하는 라이브러리인 OHIF 뷰어 라이브러리를 어렵지 않게 확인할 수 있었기 때문이다.

일단 이미지를 띄우는 것부터 차근차근 시도해보기로 했다.

문제 1. 클라이언트 환경에서만 작동

첫 번째로 맞닥뜨린 문제는 라이브러리 임포트 문제다. 페이지를 쉽게 나눌 수 있다는 장점 때문에 React는 고려도 안하고 Next.js 프레임워크를 이용했다. 하지만 Next.js 에서 cornerstone의 dicom-image-loader를 불러오려고 하면 문제가 생겼다.

⨯ TypeError: ep.addEventListener is not a function
    at (ssr)/./node_modules/.pnpm/@cornerstonejs+dicom-image-_83e8546450ee7960961e6fd209243c8d/node_modules/@cornerstonejs/dicom-image-loader/dist/esm/decodeImageFrameWorker.js (C:\Users\human-03\Projects\soai_final\cs-example.next\server\vendor-chunks@cornerstonejs+dicom-image-_83e8546450ee7960961e6fd209243c8d.js:50:1)

위와 같은 에러가 뜨는 것인데, 마땅한 해결책으로는 해당 컴포넌트를 클라이언트 컴포넌트로 변경해야 된다는 것이다. 하지만 cornerstone 라이브러리를 불러오는 컴포넌트는 이미 클라이언트 컴포넌트였다.

이 문제를 해결하기 위해서 라이브러리 importuseEffect 안에서 진행시켜야 했다. useEffect 내에서 해당 라이브러리를 import 하기 이전에 브라우저 환경에서만 제공되는 document나 window의 객체의 유무를 먼저 체크하는 것이다. 간단하게 코드로 확인해보면 다음과 같다.

import { init as csCoreInit } from "@cornerstonejs/core";
import { init as csToolInit } from "@cornerstonejs/tools";

useEffect(() => {
  const setup = async () => {
    const session = await getSession();
    if (!session?.accessToken) {
      console.log("세션이 없습니다.");
      return;
    }

    // Init libraries
    csCoreInit();
    csToolInit();
    const { init: csImageLoaderInit } = await import(
      "@cornerstonejs/dicom-image-loader"
    );
    csImageLoaderInit();
  };

  if (!isInit) {
    setup().then(() => setInit(true));
  }
}, [isInit, status]);

원인은 next.js의 작동 방식에 있다. 클라이언트 컴포넌트라고 해도 서버에서 컴포넌트 번들을 만들고 난 이후에 브라우저에 전달하는 방식을 채택하고 있어서, 라이브러리 내부에 DOM을 이용하는 작업이 있으면 해당 객체가 없는 서버 측에서 이루어지기 때문에 에러가 일어나는 것이다.

이번 문제를 해결하면서 모듈을 import 해오는 방식을 동적으로 할 수 있다는 것을 배우게 되었다.

문제 2. imageLoader의 작동 원리

cornerstone은 생소한 방식으로 DICOM 파일을 요청해 읽어들인다. 대표적인 것이 wado-rswado-uri인데 필자는 wado-uri를 이용했다. wado-uri는 DICOM 파일을 다루는 서버에서 특정 파일을 요청해올 때 쓰는 방식이다.

기존에 HTTP 프로토콜을 사용할 때는 http://example.com/file/path/identification의 방식으로 리소스를 요청해왔다. wado-uri는 이 HTTP 프로토콜 형식의 URI 중 protocol 이름만 wadouri로 변경하면 된다. 필요한 의료이미지의 URI를 배열 형태로 ImageLoader 전달하면 내부적으로 wadouri를 http로 변경해서 파일을 불러오는 것 같다. 해당 URI에 리소스를 요청하면 DICOM 파일이 반환되면 된다.

아직 wado-uri의 작동방식에 대한 감이 없을 때, 우선 Next.js 프로젝트 내부 public 폴더에 DICOM 포맷 파일을 넣어놓고서 불러오는 방식으로 이미지가 불러와지는지 확인해봤다.

const renderingEngine = new RenderingEngine(renderingEngineId);
const viewportInput = {
  viewportId,
  element,
  type: Enums.ViewportType.STACK,
};
renderingEngine.enableElement(viewportInput);

const viewport = renderingEngine.getViewport(
  viewportId,
) as StackViewport;

await viewport.setStack(["wadouri://localhost:4000/public/dummy.dcm"]);

위 방식으로 DICOM 파일을 불러와 이미지를 렌더링되는 것을 확인하고 난 이후에는, 서버에 연결해 동적으로 DICOM 파일을 불러오는 것은 어렵지 않았다.

문제 3. 요청 인증 헤더 Authorization의 설정

이번 문제는 해결을 어려운 방식으로 접근했다가 추후에 쉽게 해결할 수 있었다.

현재 프로젝트 구조가 Next.js 클라이언트에서 Spring 서버에 요청을 보내 파일을 반환받게 구성되어 있다. 문제는 Spring 서버가 모든 요청에 대해서 jwt 토큰을 요구한다는 것이다. API 요청에 대해서 기본적으로 헤더의 Authorization 필드에 유효한 JWT 방식의 Bearer Token을 전달해야 했다. 하지만 wadouri만 전달하여 DICOM 파일을 요청해오는 코드만 작성한 시점에서, 헤더를 설정하는 방법을 찾기는 힘들었다.

해결방법은 의외로 간단했다. cornerstone.jsdicom-image-loader 모듈을 통해 이미지를 불러오는 API를 제공하고 있다. 이 모듈을 앞으로는 loader라고 하겠다. loader를 사용하기 위해서는 해당 모듈을 사용하기 위한 초기화 작업을 해야한다.

위에서도 모듈을 초기화하는 작업에 대해서 설명한 바 있다. 하지만 작성해놓은 코드를 살펴보면 단순하게 초기화 함수만 실행하는 것을 확인할 수 있다.

헤더를 설정하려면 다음에 오는 코드를 살펴보면 된다.

// image loader 초기화
const { init: csImageLoaderInit } = await import(
  "@cornerstonejs/dicom-image-loader"
);
csImageLoaderInit({
  beforeSend(xhr) {
    xhr.setRequestHeader(
      "Authorization",
      `Bearer ${accessToken}`,
    );
  },
});

이번에 작업한 프로젝트는 JWT 토큰을 이용하여 인증을 구현하고 있다. 클라이언트에서 JWT를 저장하고 있다가 필요할 때 불러서 사용하고 있다. accessToken에는 JWT 토큰 값이 들어가면 된다.

이 방식 말고 imageLoader를 직접 구현하는 방식이 존재한다. imageLoader를 구현하려면 직접 서버에 DICOM 파일을 요청하고, 해당 파일을 바이트 단위로 맵핑되는 데이터 위치를 통해 파싱을 해서 이미지 데이터를 추출해서 viewport에 넘겨주는 방식이다. 하지만 헤더 하나 집어 놓는 방식은 위의 방식이 더 나은 듯 하다.

배운 점

DICOM 이라는 생소한 포맷의 의료 도메인 이미지를 다루게 되었다. 이번에는 StackViewport를 통해 2d 이미지를 다뤘지만 사실 CT나 MRI 기기로 촬영하면 영상이 나오기 때문에 추후 Volume을 다뤄보는 것도 좋을 것 같다.

또한 이번 포스트에서는 cornerstone.js의 툴을 이용하는 것을 기록하지는 못했는데, 빠른 시일내에 다른 포스트를 만들어 공유하도록 준비하려고 한다.

오랜만에 AI 없이 공식문서와 레포를 직접 찾아가며 구현했다. 왠지 예전 낭만을 느껴본 것 같아 즐거운 시간이었다.

TIL - "cornerstone.js"로 의료이미지 다루기 - ALROCK Blog