배경

우리가 담당하는 A서비스는 A서버가 담당한다. A서비스에서는 특정 의학영상(이미지)를 업로드하고 수시로 조회할 수 있는 기능을 제공한다. 각 이미지는 메타정보에 따라서 작은 단위(Sequence)로 묶인 후 더 큰 단위(study)로 묶이게 된다. 이는 영상의학에 관련한 도메인 지식이므로 자세한 설명은 아래의 ChatGPT가 대신해준다.

의학영상 데이터를 그룹핑하는 방법에는 일반적으로 세 가지 주요 레벨이 있습니다: instance, sequence, study입니다. 각 레벨은 서로 다른 정보를 포함하며, 이해하기 위해 다음과 같이 설명할 수 있습니다.

  1. Instance
    Instance는 가장 작은 단위로, 실제로 촬영된 단일 이미지나 슬라이스를 의미합니다. 예를 들어, MRI나 CT 스캔의 한 슬라이스나 X-ray의 한 장의 이미지가 instance가 됩니다. Instance는 픽셀 데이터와 메타데이터(이미지의 해상도, 촬영 시간 등)를 포함합니다.

  2. Sequence (또는 Series)
    Sequence는 특정한 프로토콜이나 목적을 위해 동일한 설정으로 연속적으로 촬영된 이미지들의 집합을 의미합니다. 예를 들어, MRI 스캔에서 T1-가중치 이미지 시퀀스, T2-가중치 이미지 시퀀스가 각각 하나의 sequence가 됩니다. Sequence는 여러 instance로 구성되며, 공통된 메타데이터(촬영 방법, 설정 등)를 공유합니다.
  3. Study
    Study는 한 환자에 대해 특정한 시점에서 수행된 전체 검사나 시리즈의 집합을 의미합니다. 예를 들어, 환자가 병원에서 MRI, CT, X-ray 등을 같은 날 또는 동일한 검사 세션 동안 촬영한 모든 이미지들이 한 study로 그룹핑됩니다. Study는 여러 sequence로 구성되며, 환자 정보, 검사 목적, 검사 날짜 등의 메타데이터를 포함합니다. 이와 같은 구조는 특히 DICOM(Digital Imaging and Communications in Medicine) 표준을 따르는 시스템에서 중요합니다. DICOM은 의료 이미징 정보를 저장하고 전달하기 위한 국제 표준이며, 각 레벨별로 구조화된 메타데이터를 포함합니다.

예시
Instance: CT 스캔의 한 슬라이스 이미지 (이미지 파일 1개). Sequence: 특정 부위를 찍은 CT 스캔 슬라이스들의 집합 (예: 100개의 연속적인 이미지). Study: 특정 환자가 동일한 세션에서 촬영한 모든 CT 스캔, MRI 스캔 등 (여러 sequence의 집합). 이러한 그룹핑 방법은 데이터의 조직과 관리를 용이하게 하며, 특히 대규모 의료 영상 데이터베이스에서 효과적으로 사용됩니다.

앞으로 이 글에서는 의학영상이미지에 대한 전문 도메인 용어를 사용하고자 한다. (Instance, Sequence, Study)

사용자 입장에서 우리 A서비스를 사용할 때는 Instacne에 직접 접근하지 않는다. 보통 그보다는 상위의 개념 그룹에서 그래프 탐색으로 접근하는 것이 일반적이다. 여기서 표현되지 않지만 환자에 대한 정보는 Subject로 표현되는데 JPA 엔티티 관점에서는 환자정보 관점에서 Study에 대해 1:N의 관계를 가진다고 볼 수 있다.

따라서 개발자 관점에서 추상화하자면 환자정보부터 Instance까지의 도메인 엔티티는 4 레벨의 계층을 가지는 1:N 중첩구조를 가진다고 볼 수 있다.

요구사항과 설계과정 그리고 고찰

이 상황에서 신규 요구사항이 생겼다. 회사 내 다른 솔루션인 B서비스에서는 앞서 말한 환자정보부터 Instance까지 도메인을 전문적으로 관리하는데 이러한 B서비스에서 저장되는 정보 조회 기능을 A서비스에서 연동되게 해야 했다.

우선 A서비스와 B서비스가 다루는 도메인 정보는 유사하지만 다르게 관리되고 있었다. enum 값으로 입력되는 문자열의 분류가 다르기도 해서 같은 정보를 나타내지만 다른 용어로 저장되기도 했다. 또한 연관된 엔티티의 구성이나 레벨구성에 차이가 있어서 A서비스에서 조회가 발생할 때마다 B서비스의 DB에 접근하여 데이터를 가져와서 A서비스에 맞춰 사용자에게 표현해주는 것은 실이 많을 것으로 생각했다. 그렇게 하면 A서비스는 B서비스 조회를 위해서 새로운 비지니스로직을 기존 로직에 추가해야하며 B서비스 정보를 판별하여 서비스 로직을 스위칭해야하고 동시에 다른 구조를 가진 B서비스의 엔티티 구조또한 클래스로 가지고 있어야 하기 때문이다. 이는 DB인프라에 지나치게 강하게 결합되어서 서버에게 영향을 미칠 수 있었다. 이 방법은 유연함이 부족하고 B서비스의 DB장애시 A서비스까지 연쇄적으로 장애가 발생하기 때문에 이 방법은 제외했다.

대신 서버간 통신에서 정해진 API Key로 인증을 하고 정해진 DTO로 필요한 도메인 정보를 Pulling 할 수 있도록 설계 했다. 가져온 데이터는 단순히 사용자의 요청에 따라 변환하여 표현해주지 않고 A서비스의 DB에 저장한 후 사용자의 요청이 발생하면 A서비스에 저장된 DB에 담긴 정보를 사용자에게 제공하게 했다. 이는 실시간 연동은 아니지만 실시간 업데이트가 꼭 필요한 요구사항은 아니었기 때문이다. 일반적으로 하루에 두번 정도의 연동이면 충분했다.

또한 B서비스에서 실제 의학영상데이터 파일 하나의 정보를 담는Instacne에서 s3 버킷에 파일 경로를 저장하고 있었다. A서비스에서는 B서비스의 S3 버킷에 접근할 권한을 얻기 위해서 AWS IAM Profile을 설정하여 A서비스의 EC2에서는 EC2자체적인 인증으로 B서비스의 S3버킷에 있는 파일에 대해서 GET요청이 가능하도록 설정했다. 그리고 이미지 파일을 Pulling 시에 A서비스 S3버킷에 복사하지 않고 DB에 경로만을 저장함으로써 A서비스에서 조회가 발생할 때 DB에 저장된 B서비스의 S3버킷으로 presigned-url 요청으로 이미지를 제공하도록 했다.

구현에 어려움이 있었던 것은 A서비스의 기존 서비스 로직에서 해당 부분을 잘 “녹여내는” 것이었다. 기존의 A서비스에서는 A서비스의 S3버킷만을 단독으로 사용할 것을 가정하고 코드가 작성되어 있었기 때문에 모든 Instacne를 다룰 때마다 A서비스의 S3버킷를 다루도록 하드코딩 되어 있었다. 이렇게 하나의 인프라정보에 연관된 클래스가 지나치게 많았기 때문에 나는 기존 로직을 대부분 손대지 않았다. 대신 A서비스에서 Instance를 관리하는 엔티티에 하나의 Nullable한 필드를 추가했고 Instance정보를 읽어 올 때 해당 필드를 검사하여 B서비스의 정보인 경우 새로운 만든 서비스 로직으로 우회하여 이미지 정보를 가져오도록 설계했다. 결국 기존 서비스 로직에 대한 수정을 최소화 했다.

나는 이 방법은 마음에 들지는 않았지만 당장 시급한 요구사항을 처리하기 위한 가장 간단한 방법이었기에 적용하기로 했다. 내가 마음에 들지 않았던 이유는 Instance 영상 의미지 정보를 가져온다는 비지니스 로직이 하나의 서비스 로직에서 가져오는 것이 아니라 복수의 로직에서 가져오기 때문에 나중에 요구사항 변경에 대응하기가 불리하기 때문이다. 개선책으로 생각해본 것은 단순히 /로 시작하는 S3버킷 내부 경로만을 저장하는 것이 아니라 버킷정보 또한 저장하여서 불필요한 서비스 로직을 줄이는 것이다. 이는 많은 코드 수정과 테스트가 필요하기 떄문에 당장은 구현이 어려웠고 차기에 적용해봐야겠다고 생각했다.

B서버 측 API가 완성되기 전에 B서비스 DB에서 네이티브 SQL 쿼리의 러프한 성능 테스트를 해보았다. 일반사용자의 유즈케이스 하나의 실행에 필요한 데이터 묶음으로 B서버에서 가장 낮은 레벨의 엔티티인 Instance약 1만개 이하의 정보를 가져오기 위해서 약 20초가 걸렸는데 이는 상위 엔티티 약 4개의 테이블을 JOIN 해야했고 B 서비스의 DB는 2개로 나뉘어 있었기 때문에 2개의 DB가 연결되어야 했기 때문이다. 그마저도 각 엔티티간 연관 관계가 문자열 유니크아이디로 1:N으로 맺어져 있었는데 이마저도 유니크 제약조건, 인덱스 지정이 되지 않았기 때문에 해당 유니크 아이디를 찾기 위해서 테이블의 전체 조회가 발생한 것으로 예상했다. 내가 예상한 시간은 약 1초 안팎인데 지나치게 길었다.

나는 B서비스의 개발자와 위 테스트를 하면서 위와 같은 생각을 했지만 B 서비스의 개발자에게 내 생각을 말하지 않았다. 우선 B 서비스는 다른 서비스와 API로 연동하는 것이 처음인 것으로 알고 있었고 B 서비스의 개발자가 이 API 연동 건 외 다른 복수의 요구사항을 처리하느라 여유가 없으리라고 생각되었기 떄문이다. 또한 현재의 요구사항이 실시간 연동을 요구한 것은 아니고 하루에 2회정도의 Pulling을 통한 A서비스 DB를 업데이트를 할 수 있다면 요구사항을 충족시킬수 있었다. 또한 B서비스의 성능개선을 위한 작업이 API 명세를 변경하지 않을 것이기 때문에 이후 고성능이 필요한 요구사항이 발생했을 때에 제안하면 될 것 같았다. 또한 B서비스는 약 2~3년에 걸쳐서 개발, 서비스를 동시에 진행하고 있기 때문에 운영 중의 설계 변경이 반복되어 코드가 복잡한 것 같았다.

문뜩 B서비스의 상황을 보면서 든 생각이 있었다. A 서비스와 B서비스 외로 C서비스와 연동이 발생할 가능성이 있다. A서비스는 기본적으로 플랫폼 서비스기 때문에 다양한 소스로부터 정보를 저장하고 가져오기도 한다. 이렇 듯 분산환경에서 데이터를 관리하는 것이 일반적으로 사용하는 DB에서 Auto Increase 되는 Long 타입의 PK를 사용하는 것이 불리하다고 생각했다. 당장은 1만개 이하의 정보를 외부 서비스에서 가져와 DB에 저장하지만 앞으로는 그것이 수백만개로 늘어날 수 있다. 또한 연동 시점이 사용자의 요청에 실시간으로 대응해야한다면 대량의 Update나 Insert가 수시로 발생할 수 있다. 이런 상황에서 PK는 애플리케이션에서 UUID와 비슷한 형태로 발행하여 지정하는 편이 좋겠다고 생각했다.

또한 A서비스의 기존 엔티티 연관관계가 양방향이고 자식 엔티티의 관리를 부모가 관리하도록 도메인 로직이 구성되어 있어서 수정 삭제 등의 작업을 위해서는 연관관계의 3레벨 위의 부모의 부모를 모두 가져와서 관리해야하는 불편함이 있었다. 이는 너무 지나치게 결합되어 있는 형태라고 생각되었다. 이를 단순화하여 확장에 용이하도록 변경해야겠다고 생각했다.