본문 바로가기
인턴

[Azure] CRM 리치 텍스트 이미지 저장 구조 개선기: Dataverse에서 Azure Blob Storage로

by 칙칙폭폭 땡땡 2026. 6. 7.
반응형

들어가며

이번 작업은 단순히 “이미지를 어디에 저장할 것인가”를 정하는 문제가 아니었다.

처음에는 CRM에서 제공하는 기본 리치 텍스트 이미지 저장 방식을 활용하면 충분할 것이라고 생각했다. 실제로 CRM 화면에서 엔지니어가 리치 텍스트 에디터에 이미지를 첨부하면, 이미지는 CRM 안에서 정상적으로 저장되고 본문에서도 잘 표시됐다.

프론트엔드에서도 비슷한 방식으로 구현하면 고객이 문의를 접수할 때 첨부한 이미지가 CRM에서도 그대로 보일 수 있을 것 같았다. 기능 관점에서는 꽤 자연스러운 접근이었다.

하지만 구현을 진행하면서 중요한 문제가 보였다.

이미지 파일이 Dataverse 쪽에 계속 쌓이는 구조였고, Dataverse의 파일/이미지 저장 용량은 무한하지 않았다. 문의 접수 기능은 운영이 시작되면 이미지가 계속 누적될 가능성이 높은 영역이다. 문의 본문, 처리 내용, 첨부 이미지가 계속 쌓이면 어느 시점에 Dataverse 용량이 빠르게 차오를 수 있었다.

그래서 기존 구조를 그대로 유지하기보다는, 이미지 바이너리는 Azure Blob Storage에 저장하고 Dataverse에는 본문 HTML과 이미지 메타데이터만 저장하는 구조로 변경하기로 했다.

이 글에서는 기존 구조에서 어떤 문제를 발견했는지, 왜 Azure Blob Storage로 분리했는지, 그리고 최종적으로 어떤 흐름으로 개선했는지를 정리한다.


1. 처음에는 CRM 기본 이미지 저장 방식을 활용했다

처음 구현 방향은 단순했다.

CRM에서 엔지니어가 리치 텍스트 에디터에 이미지를 넣으면 이미지가 정상적으로 저장되고, 본문 안에서도 표시되는 것을 확인했다. 내부적으로는 리치 텍스트에 삽입된 이미지 파일이 Dataverse의 리치 텍스트 파일 관련 테이블에 저장되는 방식이었다.

이 동작을 확인한 뒤, 프론트엔드에서도 고객이 리치 텍스트 에디터에 이미지를 첨부하면 동일한 방식으로 저장되도록 구성했다.

당시 흐름은 대략 다음과 같았다.

고객이 문의 작성 화면에서 이미지를 첨부한다.

프론트엔드는 이미지를 Dataverse 쪽에 저장한다.

문의 접수 버튼을 누르면 서비스케이스 레코드가 생성된다.

이미지와 서비스케이스가 연결된다.

CRM에서 해당 문의를 열면 본문과 이미지가 함께 보인다.

기능만 놓고 보면 이 방식은 잘 동작했다. 고객 포털에서 등록한 이미지가 CRM에서도 보이고, CRM의 기본 리치 텍스트 처리 방식과도 어느 정도 맞아 보였다.

처음에는 이 방식이 가장 빠르고 단순한 해결책처럼 보였다.


2. 기존 구조를 조금 더 자세히 보면

기존 구조에서는 이미지 파일 자체가 Dataverse에 저장되는 형태였다.

본문 HTML에는 이미지가 삽입되어 있고, 그 이미지를 표시하기 위한 파일은 Dataverse 내부의 리치 텍스트 파일 저장 영역에 들어간다. 즉, 사용자는 단순히 본문에 이미지를 넣었다고 생각하지만 실제로는 이미지 바이너리가 Dataverse 저장 공간을 사용하게 된다.

기존 구조를 그림으로 표현하면 다음과 같다.

기존 구조는 다음과 같은 장점이 있었다.

첫 번째, 구현이 빠르다.

CRM의 기본 리치 텍스트 이미지 저장 방식을 활용하기 때문에 별도의 이미지 저장소를 새로 설계하지 않아도 된다. 이미지 업로드, 저장, 본문 표시 흐름을 CRM과 Dataverse의 기본 구조에 기대어 처리할 수 있다.

두 번째, CRM 화면에서 바로 확인할 수 있다.

이미지가 CRM 내부 구조에 저장되므로, CRM에서 본문을 열었을 때 이미지가 표시되는 흐름이 자연스럽다. 별도의 외부 저장소 URL을 연결하거나, 이미지 접근 권한을 따로 고민하는 범위가 상대적으로 적다.

세 번째, 초기 기능 검증이 쉽다.

일단 “고객이 이미지를 첨부하면 CRM에서도 보여야 한다”는 요구사항만 보면, 기존 방식은 빠르게 기능을 완성할 수 있는 방법이다.

하지만 이 방식은 운영 관점에서 다시 봐야 했다.


3. 문제는 기능이 아니라 운영 안정성이었다

기존 방식의 가장 큰 문제는 “당장 동작하지 않는다”가 아니었다.

오히려 기능은 동작했다. 이미지는 저장됐고, CRM에서도 보였다. 그래서 처음에는 문제가 없어 보였다.

하지만 운영 관점에서 생각하면 이야기가 달라진다.

문의 접수 기능은 한 번 만들고 끝나는 기능이 아니다. 운영이 시작되면 고객 문의가 계속 쌓인다. 문의마다 이미지가 1장만 들어가는 것도 아니다. 상황 설명을 위해 스크린샷을 여러 장 첨부할 수도 있고, 엔지니어가 처리 내용을 작성하면서 추가 이미지를 넣을 수도 있다.

그렇게 되면 이미지 파일은 계속 누적된다.

Dataverse는 기본적으로 업무 데이터를 저장하고 관리하는 플랫폼이다. 서비스케이스, 고객 정보, 처리 상태, 담당자, 이력 같은 데이터는 Dataverse에 저장하는 것이 자연스럽다. 하지만 이미지 파일 같은 바이너리 데이터를 계속 쌓아두는 저장소로 사용하기에는 부담이 있다.

특히 Dataverse의 파일/이미지 저장 용량은 제한되어 있고, 실제 운영에서 이미지가 얼마나 빠르게 쌓일지는 예측하기 어렵다.

초기에는 괜찮아 보일 수 있다. 하지만 시간이 지나면서 문의가 많아지고 이미지 첨부가 많아지면, Dataverse 용량이 예상보다 빠르게 증가할 수 있다.

이 문제는 나중에 발견하면 대응하기 더 어렵다.

이미 운영 중인 이미지가 Dataverse에 많이 쌓인 뒤에 저장 구조를 바꾸려면, 기존 이미지 마이그레이션, 본문 HTML 치환, 접근 URL 변경, 레코드 연결 재정리 같은 작업이 추가로 필요해진다.

그래서 운영 전에 구조를 바꾸는 것이 더 안전하다고 판단했다.


4. 기존 방식의 핵심 리스크

기존 방식의 리스크는 크게 네 가지로 정리할 수 있다.

4.1 Dataverse 용량 증가

이미지가 Dataverse에 저장되면 이미지 파일 크기만큼 Dataverse 파일/이미지 저장 용량을 사용한다.

텍스트 데이터는 상대적으로 작지만 이미지는 다르다. 스크린샷 한 장만 해도 수백 KB에서 몇 MB까지 갈 수 있다. 사용자가 여러 장의 이미지를 첨부하면 문의 한 건의 저장 용량이 빠르게 커진다.

문의가 누적되면 이 차이는 더 커진다.

텍스트 중심 데이터만 쌓이는 시스템과 이미지 파일이 함께 쌓이는 시스템은 장기 운영에서 저장 용량 증가 속도가 다르다.

4.2 용량 증가 속도를 예측하기 어렵다

운영 전에 정확히 예측하기 어려운 부분이 있다.

문의가 하루에 몇 건 들어올지, 문의마다 이미지가 몇 장 첨부될지, 이미지 평균 크기가 어느 정도일지, 엔지니어가 처리 내용에 이미지를 얼마나 자주 넣을지 같은 요소들은 실제 운영을 해봐야 알 수 있다.

그래서 단순히 “현재 테스트 환경에서는 문제가 없다”는 이유만으로 Dataverse에 이미지를 계속 저장하는 것은 위험할 수 있다.

4.3 Dataverse를 파일 저장소처럼 사용하게 된다

Dataverse는 업무 데이터 관리에 강점이 있다.

하지만 이미지 파일 저장 자체는 Azure Blob Storage 같은 파일 저장소가 더 적합하다. 이미지 파일은 저장, 조회, 경로 관리, 만료 처리, 삭제 정책, 접근 제어 같은 요소가 중요하다.

이런 책임을 Dataverse에 계속 맡기면 Dataverse가 본래 담당해야 하는 업무 데이터 관리 역할과 파일 저장소 역할이 섞이게 된다.

역할이 섞이면 나중에 유지보수가 어려워진다.

4.4 나중에 구조를 바꾸기 어렵다

초기에는 Dataverse에 이미지를 저장하다가, 운영 중간에 Blob Storage로 바꾸는 것도 가능은 하다.

하지만 이미 저장된 이미지가 많아진 뒤에는 일이 복잡해진다.

기존 이미지 파일을 Blob Storage로 옮겨야 한다.

본문 HTML 안의 이미지 경로를 새 Blob URL로 바꿔야 한다.

이미지와 서비스케이스 간 연결 정보를 다시 만들어야 한다.

기존 CRM 화면에서 깨지는 이미지가 없는지 확인해야 한다.

권한과 접근 정책도 다시 점검해야 한다.

즉, 저장 구조는 운영 초기에 잘 잡는 것이 중요하다.


5. 개선 방향: 이미지 저장 책임 분리

그래서 개선 방향은 명확했다.

이미지 파일 자체는 Azure Blob Storage에 저장한다.

Dataverse에는 서비스케이스 본문 HTML과 이미지 메타데이터만 저장한다.

즉, 이미지 바이너리와 업무 데이터를 분리한다.

이렇게 나누면 각 시스템의 역할이 명확해진다.

Dataverse는 서비스케이스, 문의 본문, 처리 상태, 담당자, 이미지 메타데이터 같은 업무 데이터를 관리한다.

Azure Blob Storage는 실제 이미지 파일을 저장한다.

백엔드는 이미지 업로드, 임시 저장, 최종 저장, URL 치환, 메타데이터 기록을 담당한다.

프론트엔드는 사용자가 이미지를 삽입하고 문의를 접수하는 흐름을 담당한다.

개선 후 구조는 다음과 같다.

이 구조의 핵심은 Dataverse에 이미지를 직접 저장하지 않는 것이다.

Dataverse에는 이미지 파일을 저장하는 대신, 이미지가 어디에 저장되어 있는지를 나타내는 URL과 메타데이터를 저장한다.

본문 HTML에는 Blob Storage에 저장된 이미지 URL이 들어간다.

그래서 CRM이나 고객 포털에서 본문을 렌더링하면 HTML 안의 이미지 URL을 통해 이미지를 표시할 수 있다.


6. 개선 구조의 장점

개선 구조의 장점은 단순히 “용량을 줄인다”에서 끝나지 않는다.

6.1 Dataverse 용량 리스크를 줄일 수 있다

가장 직접적인 장점은 Dataverse의 파일/이미지 저장 용량 사용을 줄일 수 있다는 점이다.

이미지 바이너리를 Blob Storage에 저장하면 Dataverse에는 상대적으로 작은 텍스트 데이터와 메타데이터만 남는다.

예를 들어 Blob URL, Blob 경로, 파일명, MIME 타입, 파일 크기, 업로드 상태 같은 값들은 이미지 파일 자체에 비해 훨씬 작다.

이렇게 하면 Dataverse는 업무 데이터 중심으로 유지할 수 있다.

6.2 파일 저장소와 업무 데이터 저장소의 역할이 분리된다

Blob Storage는 파일 저장에 적합하다.

Dataverse는 업무 데이터 관리에 적합하다.

각각의 시스템이 잘하는 일을 맡도록 분리하면 전체 구조가 더 명확해진다.

나중에 이미지 저장 정책을 바꿔야 할 때도 백엔드와 Blob Storage 쪽에서 처리하면 된다. Dataverse의 핵심 서비스케이스 구조를 크게 흔들 필요가 줄어든다.

6.3 이미지 관리가 쉬워진다

Blob Storage를 사용하면 이미지 경로를 서비스케이스 기준으로 정리할 수 있다.

예를 들어 서비스케이스 ID를 기준으로 이미지 파일을 묶어두면, 특정 문의에 연결된 이미지들을 추적하기 쉽다.

또한 temp 경로와 최종 경로를 분리하면 작성 중 업로드된 이미지와 실제 문의에 연결된 이미지를 구분할 수 있다.

이미지 삭제, 만료, 정리 작업도 설계하기 쉬워진다.

6.4 CRM과 고객 포털이 같은 이미지를 볼 수 있다

본문 HTML 안에 Blob URL이 들어가면, CRM과 고객 포털이 같은 HTML을 기준으로 이미지를 렌더링할 수 있다.

즉, 고객 포털에서 작성한 이미지가 CRM에서도 보이고, CRM에서 작성한 처리 내용 이미지도 고객 포털에서 볼 수 있는 구조를 만들 수 있다.

물론 이때 이미지 접근 권한과 URL 정책은 별도로 설계해야 한다.


7. 왜 temp 업로드가 필요했는가

이번 구조에서 가장 중요한 설계 포인트는 temp 업로드와 commit API였다.

처음에는 이미지를 Blob Storage에 바로 저장하면 되는 것처럼 보일 수 있다. 하지만 실제 문의 작성 흐름을 보면 문제가 있다.

이미지는 사용자가 문의를 작성하는 도중에 첨부된다.

그런데 이 시점에는 아직 서비스케이스 레코드가 생성되지 않았다.

즉, 최종 Blob 경로를 서비스케이스 ID 기준으로 만들고 싶어도, 이미지 업로드 시점에는 서비스케이스 ID가 없다.

예를 들어 최종 경로를 다음과 같이 잡고 싶다고 하자.

cases/{caseId}/images/{fileId}.png

그런데 사용자가 이미지를 첨부하는 시점에는 {caseId}가 아직 없다. 문의 접수 버튼을 누르기 전이기 때문이다.

그래서 이미지를 바로 최종 경로에 넣을 수 없다.

이 문제를 해결하기 위해 먼저 이미지를 temp 경로에 저장하고, 문의 접수가 완료된 뒤에 최종 경로로 확정하는 구조가 필요했다.


8. temp 업로드와 commit API 흐름

최종 흐름은 다음과 같이 정리했다.

사용자가 리치 텍스트 에디터에 이미지를 삽입한다.

프론트엔드는 이미지 업로드 API를 호출한다.

백엔드는 이미지를 Azure Blob Storage의 temp 경로에 저장한다.

백엔드는 임시 이미지 URL, 업로드 토큰, 세션 ID 등을 프론트엔드에 반환한다.

프론트엔드는 반환받은 이미지 URL을 본문 HTML 안에 삽입한다.

사용자가 문의 접수 버튼을 누른다.

프론트엔드는 Dataverse에 서비스케이스 레코드를 생성한다.

서비스케이스가 생성되면 caseId가 확보된다.

프론트엔드는 caseId와 본문 HTML, 업로드 세션 정보를 가지고 commit API를 호출한다.

백엔드는 temp 경로의 이미지를 최종 경로로 이동하거나 복사한다.

백엔드는 본문 HTML 안의 temp 이미지 URL을 최종 이미지 URL로 치환한다.

백엔드는 이미지 메타데이터를 Dataverse에 저장한다.

백엔드는 최종 HTML을 Dataverse의 본문 필드에 저장하거나 업데이트한다.

이 흐름에서 commit API는 단순한 후처리 API가 아니다.

문의 작성 중 임시로 올라간 이미지들을 실제 서비스케이스에 소속시키는 핵심 단계다.


9. temp 상태가 필요한 이유

temp 상태가 필요한 이유는 여러 가지다.

9.1 사용자가 이미지를 올리고 문의 접수를 취소할 수 있다

사용자가 이미지를 첨부했지만 문의 접수를 하지 않을 수도 있다.

작성하다가 페이지를 닫을 수도 있고, 내용을 지울 수도 있고, 제출을 취소할 수도 있다.

이 경우 이미지는 실제 서비스케이스에 연결되지 않은 상태로 남는다.

따라서 이런 이미지는 최종 이미지가 아니라 임시 이미지로 관리해야 한다.

나중에 일정 시간이 지나면 정리할 수 있도록 만료 시간이나 상태값을 둘 필요가 있다.

9.2 하나의 문의 작성 세션에 여러 이미지가 들어갈 수 있다

문의 하나를 작성하는 동안 이미지를 여러 장 첨부할 수 있다.

이때 각 이미지를 같은 업로드 세션으로 묶어두면 commit 시점에 어떤 이미지들이 이번 문의에 포함된 이미지인지 알 수 있다.

그래서 업로드 세션 ID가 필요하다.

9.3 본문 HTML 안의 URL을 나중에 바꿔야 한다

처음 본문 HTML에 들어가는 이미지는 temp URL일 수 있다.

하지만 문의가 접수되고 최종 경로가 정해지면 HTML 안의 이미지 URL도 최종 URL로 바뀌어야 한다.

따라서 commit API에서는 단순히 파일만 옮기는 것이 아니라, 본문 HTML도 함께 수정해야 한다.

9.4 실패 상황을 추적해야 한다

이미지 업로드는 성공했지만 commit이 실패할 수 있다.

또는 서비스케이스는 생성됐지만 이미지 최종 이동이 실패할 수도 있다.

이런 경우를 처리하려면 각 이미지의 상태를 추적할 수 있어야 한다.

예를 들어 temp, committed, failed, deleted 같은 상태값을 둘 수 있다.


10. Blob Storage 경로 설계

Blob Storage를 사용할 때는 경로 설계도 중요하다.

단순히 모든 이미지를 한 폴더에 저장하면 나중에 관리하기 어렵다. 어떤 이미지가 어떤 문의에 속했는지, 고객이 올린 이미지인지 엔지니어가 올린 이미지인지, 임시 이미지인지 최종 이미지인지 구분하기 어렵다.

그래서 경로는 의미를 갖도록 설계하는 것이 좋다.

예를 들어 다음과 같은 기준으로 나눌 수 있다.

임시 업로드 이미지

temp/{uploadSessionId}/{fileId}.{ext}

고객 문의 본문 이미지

cases/{caseId}/customer/{fileId}.{ext}

엔지니어 처리 내용 이미지

cases/{caseId}/engineer-response/{fileId}.{ext}

삭제 또는 보관 대상 이미지

archive/{caseId}/{fileId}.{ext}

중요한 것은 Blob 경로만 봐도 이미지의 성격을 어느 정도 알 수 있게 만드는 것이다.

다만 블로그에 실제 Storage Account 이름이나 실제 경로 전체를 그대로 노출하는 것은 피하는 것이 좋다.

예시 경로는 가상의 값으로 표시하고, 실제 운영 정보는 가려야 한다.


11. 이미지 메타데이터 테이블이 필요한 이유

이미지 파일을 Azure Blob Storage에 저장한다고 해서 Dataverse에 아무 정보도 남기지 않는 것은 아니다.

Blob Storage는 실제 이미지 파일을 저장하는 역할을 한다. 하지만 이 이미지가 어떤 문의에 연결된 이미지인지, 누가 업로드했는지, 현재 임시 상태인지 최종 저장 상태인지, 어떤 Blob 경로에 저장되어 있는지 같은 업무 맥락은 Blob Storage만으로 관리하기 어렵다.

특히 이번 구조에서는 Blob 컨테이너를 public으로 열지 않고, 백엔드 이미지 조회 API를 통해 이미지를 보여주는 방식으로 구성했다. 따라서 단순히 파일을 Blob Storage에 저장하는 것만으로는 충분하지 않았다.

백엔드가 이미지를 조회하려면 다음 정보가 필요하다.

  • 어떤 이미지인지 식별할 수 있는 값
  • 실제 Blob Storage 안에서 파일을 찾을 수 있는 경로
  • 요청 token이 유효한지 검증할 수 있는 token 정보
  • 이미지가 아직 temp 상태인지, 실제 서비스케이스에 연결된 상태인지 판단할 수 있는 상태값
  • 어떤 서비스케이스와 연결된 이미지인지 확인할 수 있는 관계 정보

이런 정보를 관리하기 위해 별도의 이미지 메타데이터 테이블을 두었다.

이미지 메타데이터 테이블에는 다음과 같은 정보가 들어간다.

항목 설명
이미지 ID 이미지 조회 API에서 특정 이미지를 식별하는 값
서비스케이스 룩업 이 이미지가 어떤 문의에 연결되어 있는지 나타내는 값
Blob 경로 백엔드가 Azure Blob Storage에서 실제 이미지 파일을 찾기 위한 내부 경로
이미지 조회 API URL HTML의 img src에 들어가는 백엔드 이미지 조회 주소
원본 파일명 사용자가 업로드한 파일명
저장 파일명 Blob Storage에 저장된 파일명
확장자 png, jpg, jpeg 등 파일 확장자
MIME 타입 image/png, image/jpeg 등 이미지 응답 타입
파일 크기 이미지 용량
업로드 상태 temp, active, committed, failed, deleted 등 이미지 처리 상태
업로드 세션 ID 문의 작성 중 임시 업로드된 이미지들을 묶는 값
접근 token hash 이미지 조회 요청의 token을 검증하기 위한 해시값
업로드 사용자 이미지를 업로드한 사용자
업로드 일시 이미지가 처음 업로드된 시간
커밋 일시 이미지가 실제 서비스케이스에 연결된 시간
만료 일시 temp 이미지 정리 기준 시간
삭제 여부 논리 삭제 여부
오류 메시지 업로드 또는 commit 실패 시 원인 기록

여기서 중요한 점은 실제 Blob Storage URL을 그대로 저장하고 노출하는 것이 아니라, 이미지 조회에 필요한 정보를 메타데이터로 관리한다는 것이다.

특히 이미지 조회와 관련해서 중요한 값은 fileId, blobPath, accessTokenHash, status, serviceCaseId다.

fileId는 백엔드 이미지 조회 API에서 특정 이미지를 식별하는 데 사용된다.

blobPath는 백엔드가 Azure Blob Storage 안에서 실제 이미지 파일을 찾는 데 사용된다.

accessTokenHash는 요청 URL에 포함된 token이 유효한지 검증하는 데 사용된다. 원본 token을 그대로 저장하지 않고 해시값만 저장하기 때문에, 메타데이터 테이블에 접근하더라도 원본 token이 바로 노출되지 않는다.

status는 이미지가 아직 임시 업로드 상태인지, 실제 서비스케이스에 연결된 상태인지, 삭제 또는 만료된 상태인지 판단하는 데 사용된다.

serviceCaseId는 이미지가 어떤 문의와 연결되어 있는지 추적하는 데 사용된다.

따라서 이 메타데이터 테이블은 단순히 이미지 목록을 기록하기 위한 테이블이 아니다. 백엔드가 이미지 조회 요청을 처리할 때, 요청을 검증하고 실제 Blob 파일을 찾기 위해 사용하는 핵심 기준 테이블이다.

이 테이블을 두면 운영 중 문제가 생겼을 때도 원인을 추적하기 쉽다.

예를 들어 CRM 본문에서 이미지가 보이지 않는다면 먼저 본문 HTML의 img src가 어떤 이미지 조회 API를 가리키는지 확인할 수 있다. 그 다음 fileId를 기준으로 이미지 메타데이터를 조회하고, 해당 이미지가 어떤 Blob 경로에 저장되어 있는지, 현재 상태가 active인지 failed인지, 서비스케이스와 정상적으로 연결되어 있는지 확인할 수 있다.

Blob Storage에 실제 파일이 존재하는지도 확인할 수 있고, token 검증 문제인지, Blob 파일 누락 문제인지, HTML URL 치환 문제인지도 분리해서 볼 수 있다.

즉, 이미지 메타데이터 테이블은 다음 세 가지 역할을 한다.

1. 추적 역할

이미지가 어떤 문의에 연결되어 있고, 어떤 사용자가 언제 업로드했는지 확인한다.

2. 조회 기준 역할

백엔드 이미지 조회 API가 fileIdblobPath를 기준으로 실제 Blob 파일을 찾을 수 있게 한다.

3. 보안 검증 역할

accessTokenHash를 통해 요청 token이 유효한지 확인하고, 아무 요청에나 이미지를 반환하지 않도록 한다.

이런 메타데이터가 없으면 이미지 관련 오류가 발생했을 때 원인을 찾기 어렵다. 반대로 메타데이터를 명확히 남겨두면 Blob Storage에 파일을 분리해 저장하더라도, Dataverse 안에서 업무 맥락과 이미지 상태를 안정적으로 추적할 수 있다.


12. Dataverse에 저장하는 값과 저장하지 않는 값

개선 구조에서 중요한 원칙은 Dataverse에 “이미지 파일 자체”를 저장하지 않는 것이다.

대신 Dataverse에는 다음 정보를 저장한다.

서비스케이스 본문 HTML

이미지 URL

Blob 경로

이미지 메타데이터

업로드 상태

서비스케이스와 이미지 간 연결 정보

반대로 Dataverse에 저장하지 않는 것은 다음과 같다.

이미지 바이너리 파일

리치 텍스트 이미지 첨부 파일 자체

대용량 파일 데이터

이렇게 기준을 명확히 하면 나중에 구조가 흔들리지 않는다.


13. 프론트엔드 역할

프론트엔드는 사용자가 이미지를 첨부하고 문의를 접수하는 경험을 담당한다.

기존에는 프론트엔드에서 문의 접수 버튼을 누르면 바로 Dataverse에 서비스케이스를 생성했고, 조회도 정상적으로 됐다.

하지만 Blob Storage 구조로 변경하면서 프론트엔드는 이미지 업로드와 commit API 호출 흐름을 추가로 처리해야 한다.

프론트엔드의 역할은 다음과 같다.

사용자가 리치 텍스트 에디터에 이미지를 삽입하면 이미지 파일을 감지한다.

이미지 파일을 백엔드 업로드 API로 보낸다.

업로드 API가 반환한 temp 이미지 URL을 본문 HTML의 img 태그에 넣는다.

사용자가 문의 접수를 누르면 서비스케이스를 생성한다.

생성된 서비스케이스 ID를 확보한다.

서비스케이스 ID, 본문 HTML, 업로드 세션 정보를 백엔드 commit API로 보낸다.

commit API가 반환한 최종 HTML을 기준으로 화면 상태를 갱신하거나 저장 결과를 확인한다.

프론트엔드는 Blob Storage의 내부 경로 규칙을 직접 깊게 알 필요가 없다.

가능하면 프론트엔드는 백엔드가 반환한 URL과 토큰, 세션 ID만 사용하고, 실제 저장 정책은 백엔드가 관리하는 것이 좋다.


14. 백엔드 역할

백엔드는 이미지 저장 정책의 중심이다.

이미지를 어디에 저장할지, 어떤 경로를 사용할지, temp 이미지를 언제 최종 이미지로 확정할지, HTML 안의 URL을 어떻게 치환할지, Dataverse에 어떤 메타데이터를 남길지를 백엔드가 담당한다.

백엔드의 역할은 다음과 같다.

이미지 업로드 API를 제공한다.

업로드된 이미지의 확장자와 MIME 타입을 검증한다.

이미지를 Azure Blob Storage의 temp 경로에 저장한다.

업로드 세션 ID와 파일 ID를 생성한다.

이미지 접근 URL 또는 임시 URL을 반환한다.

문의 접수 후 commit API를 제공한다.

서비스케이스 ID를 기준으로 최종 Blob 경로를 만든다.

temp 이미지를 최종 경로로 이동하거나 복사한다.

본문 HTML 안의 temp URL을 최종 URL로 치환한다.

이미지 메타데이터를 Dataverse에 생성한다.

최종 HTML을 Dataverse 본문 필드에 저장하거나 업데이트한다.

실패 시 오류 상태와 메시지를 기록한다.

백엔드가 이 책임을 가지면 구조가 안정적이다.

프론트엔드는 사용자의 화면 흐름에 집중할 수 있고, 저장소 변경이나 경로 정책 변경은 백엔드에서 관리할 수 있다.


15. commit API가 하는 일

commit API는 이번 구조에서 핵심이다.

단순히 “문의가 생성됐으니 끝”이 아니라, 작성 중 임시로 올라간 이미지를 실제 서비스케이스에 귀속시키는 단계다.

commit API가 해야 하는 일은 다음과 같다.

첫 번째, 요청으로 전달된 서비스케이스 ID를 확인한다.

두 번째, 해당 업로드 세션에 속한 temp 이미지 목록을 확인한다.

세 번째, 각 이미지가 실제 본문 HTML에서 사용되고 있는지 확인한다.

네 번째, 사용 중인 이미지를 최종 Blob 경로로 이동하거나 복사한다.

다섯 번째, 본문 HTML 안의 temp URL을 최종 URL로 치환한다.

여섯 번째, 이미지 메타데이터 테이블에 각 이미지 정보를 저장한다.

일곱 번째, Dataverse의 본문 필드를 최종 HTML로 업데이트한다.

여덟 번째, 사용되지 않는 temp 이미지가 있다면 만료 대상이나 삭제 대상으로 표시한다.

이 과정이 있어야 작성 중 업로드된 이미지와 실제 문의에 포함된 이미지를 구분할 수 있다.


16. 본문 HTML URL 치환

리치 텍스트 에디터에서 이미지를 삽입하면 본문 HTML 안에는 img 태그가 들어간다.

처음 이미지 업로드 시점에는 temp URL이 들어갈 수 있다.

예를 들어 작성 중 HTML에는 다음과 같은 이미지 URL이 들어간다고 볼 수 있다.

temp/{uploadSessionId}/{fileId}.png

하지만 문의가 접수되고 서비스케이스 ID가 생성된 이후에는 최종 경로로 바뀌어야 한다.

cases/{caseId}/customer/{fileId}.png

따라서 commit API는 본문 HTML을 받아서, 그 안에 들어있는 temp 이미지 URL을 최종 Blob URL로 바꾸는 작업을 해야 한다.

이 작업이 제대로 되지 않으면 문제가 생긴다.

본문에는 여전히 temp URL이 남아 있을 수 있다.

temp 이미지는 나중에 정리될 수 있으므로 이미지가 깨질 수 있다.

메타데이터에는 최종 URL이 있는데 본문은 temp URL을 바라보는 불일치가 생길 수 있다.

그래서 HTML URL 치환은 매우 중요한 단계다.


17. private Blob 이미지를 CRM과 커스텀 포탈에서 보여주는 방식

이미지를 Azure Blob Storage에 저장한다고 해서 컨테이너를 public으로 열어둔 것은 아니다.

문의에 첨부되는 이미지에는 고객 화면, 오류 화면, 업무 관련 정보, 내부 시스템 화면 등이 포함될 수 있다. 따라서 Blob URL만 알면 누구나 이미지를 볼 수 있는 구조는 적절하지 않다고 판단했다.

그래서 Blob Storage 컨테이너는 private 상태로 유지하고, 실제 이미지 조회는 백엔드 API를 통해 처리하는 방식으로 구성했다.

즉, HTML 안의 img src에는 Azure Blob Storage의 실제 URL을 직접 넣지 않았다. 대신 백엔드의 이미지 조회 API 주소를 넣었다.

예를 들면 다음과 같은 형태다.

/api/public-files/{fileId}/content?token={token}

이 구조에서는 CRM이나 커스텀 포탈이 본문 HTML을 렌더링할 때, 브라우저가 Blob Storage에 직접 접근하지 않는다. 브라우저는 백엔드 이미지 조회 API를 호출하고, 백엔드는 요청에 포함된 fileIdtoken을 검증한 뒤 Blob Storage에서 이미지를 읽어 응답한다.

이렇게 하면 이미지 원본은 Blob Storage에 하나만 저장하면서도, CRM과 커스텀 포탈 양쪽에서 동일한 이미지를 조회할 수 있다.

중요한 점은 CRM과 커스텀 포탈이 Blob Storage를 직접 바라보는 것이 아니라, Dataverse에 저장된 본문 HTML과 이미지 메타데이터를 기준으로 백엔드 이미지 조회 API를 호출한다는 점이다.

구조를 단순화하면 다음과 같다.

Azure Blob Storage
- 실제 이미지 파일 저장
- 컨테이너는 private 유지

Dataverse
- 본문 HTML 저장
- 이미지 메타데이터 저장
- Blob 경로 저장
- token hash 저장

CRM / 커스텀 포탈
- 본문 HTML 렌더링
- img src에 들어간 백엔드 API 호출

Backend
- fileId 조회
- token 검증
- Blob Storage에서 이미지 읽기
- 이미지 바이너리 응답


18. 이미지 메타데이터와 token 검증

이 구조에서 Dataverse의 이미지 메타데이터 테이블은 단순히 “이미지 정보 목록”만 저장하는 역할이 아니다.

이미지를 안전하게 조회하기 위한 기준 정보도 함께 저장한다.

이미지 업로드 시 백엔드는 이미지 파일을 Blob Storage에 저장하고, Dataverse 이미지 메타데이터 테이블에는 해당 이미지의 식별 정보와 Blob 경로, 업로드 상태, 연결된 서비스케이스 정보 등을 저장한다.

또한 이미지 조회를 위한 token도 함께 사용한다.

다만 원본 token을 Dataverse에 그대로 저장하지 않고, token을 해시한 값을 저장하는 방식으로 구성했다. 예를 들어 백엔드는 업로드 시 랜덤 token을 생성하고, 프론트엔드에는 원본 token이 포함된 이미지 조회 URL을 반환한다.

본문 HTML에는 다음과 같은 URL이 들어간다.

/api/public-files/{fileId}/content?token={token}

반면 Dataverse 메타데이터에는 원본 token이 아니라, 해당 token을 SHA-256 등으로 해시한 값이 저장된다.

이미지 조회 요청이 들어오면 백엔드는 다음 순서로 처리한다.

  1. 요청 URL에서 fileIdtoken을 읽는다.
  2. fileId를 기준으로 Dataverse 이미지 메타데이터를 조회한다.
  3. 요청으로 들어온 token을 같은 방식으로 해시한다.
  4. Dataverse에 저장된 token hash와 비교한다.
  5. 값이 일치하면 Blob Storage에서 이미지를 읽는다.
  6. 이미지의 MIME 타입에 맞게 응답한다.
  7. 값이 일치하지 않으면 이미지를 반환하지 않는다.

이 방식의 장점은 실제 Blob URL이나 Storage Account Key를 프론트엔드에 노출하지 않아도 된다는 점이다.

프론트엔드, CRM, 커스텀 포탈은 모두 백엔드 이미지 조회 API만 호출한다. Blob Storage에 접근할 권한은 백엔드만 가진다.

또한 Dataverse에 원본 token을 그대로 저장하지 않기 때문에, 메타데이터 테이블이 노출되더라도 원본 token이 바로 드러나지 않는다.

정리하면 이 구조의 핵심은 다음과 같다.

HTML img src
→ 백엔드 이미지 조회 API URL

Dataverse 메타데이터
→ blobPath, fileId, tokenHash, status, serviceCaseId 저장

Backend
→ token 검증 후 Blob 이미지 반환

Blob Storage
→ private 유지


17. CRM 엔지니어 처리 내용도 같은 구조로 맞추기

고객 문의 본문만 Blob Storage로 바꾸면 구조가 반쪽짜리가 될 수 있다.

CRM에서 엔지니어가 처리 내용을 작성하면서 이미지를 첨부하는 경우도 있기 때문이다.

만약 고객 포털에서 올라온 이미지는 Blob Storage에 저장하고, CRM에서 엔지니어가 넣은 이미지는 다시 Dataverse 리치 텍스트 파일에 저장된다면 이미지 저장 정책이 섞이게 된다.

그러면 운영 관점에서 다시 복잡해진다.

어떤 이미지는 Blob Storage에 있고, 어떤 이미지는 Dataverse에 있다.

이미지 조회 방식이 달라진다.

삭제 정책도 달라진다.

용량 관리 기준도 달라진다.

그래서 엔지니어 처리 내용 이미지도 동일하게 Blob Storage 기반으로 맞추는 것이 좋다.

엔지니어 처리 내용은 이미 서비스케이스가 존재하는 상태에서 작성된다.

따라서 고객 문의 작성 흐름처럼 temp 업로드가 반드시 필요한 것은 아닐 수 있다. 이미 caseId가 있으므로 바로 최종 경로에 저장할 수 있다.

예를 들어 다음과 같은 경로를 사용할 수 있다.

cases/{caseId}/engineer-response/{fileId}.png

이렇게 하면 고객 첨부 이미지와 엔지니어 처리 이미지가 모두 같은 Blob Storage 안에서 관리된다.


18. 전체 Before / After 비교

이번 개선을 Before / After로 정리하면 다음과 같다.

여기서 중요한 점은 Before에 반드시 실제 캡처가 필요하지 않다는 것이다. 현재 Before 사진이 없다면 Before는 직접 만든 구조도로 충분하다. 오히려 기존 운영 화면 캡처를 억지로 찾는 것보다, 기존 저장 흐름을 단순화한 다이어그램이 더 안전하고 이해하기 쉽다.

구분 기존 방식 개선 방식
이미지 저장 위치 Dataverse 리치 텍스트 파일 영역 Azure Blob Storage
Dataverse 역할 업무 데이터 + 이미지 파일 저장 업무 데이터 + 이미지 참조 정보 저장
본문 HTML Dataverse 내부 이미지 참조 Blob URL 기반 이미지 참조
이미지 용량 부담 Dataverse에 누적 Blob Storage에 분리
이미지 메타데이터 제한적 또는 구조화 어려움 별도 테이블로 추적 가능
temp 처리 명확하지 않음 temp 업로드 후 commit
서비스케이스 연결 문의 생성 시 연결 commit API에서 최종 연결
운영 확장성 용량 증가에 취약 저장소 분리로 확장성 개선
유지보수 CRM 기본 구조 의존 백엔드에서 저장 정책 제어
CRM/포털 이미지 조회 Dataverse 저장 이미지 기준 동일 Blob URL 기준


19. 최종 아키텍처

최종 구조는 다음과 같이 정리할 수 있다.

고객이 문의 작성 화면에서 이미지를 첨부한다.

프론트엔드는 백엔드 이미지 업로드 API를 호출한다.

백엔드는 이미지를 Azure Blob Storage의 temp 경로에 저장한다.

프론트엔드는 반환받은 이미지 URL을 리치 텍스트 본문 HTML에 삽입한다.

고객이 문의 접수를 누른다.

프론트엔드는 Dataverse에 서비스케이스 레코드를 생성한다.

생성된 서비스케이스 ID를 기준으로 commit API를 호출한다.

백엔드는 temp 이미지를 최종 경로로 확정한다.

백엔드는 HTML 안의 이미지 URL을 최종 URL로 치환한다.

백엔드는 이미지 메타데이터를 Dataverse에 저장한다.

백엔드는 최종 본문 HTML을 Dataverse에 저장한다.

CRM과 고객 포털은 같은 HTML과 Blob URL을 기준으로 이미지를 조회한다.


20. 운영 중 고려해야 할 부분

구조를 바꿨다고 해서 모든 문제가 자동으로 해결되는 것은 아니다.

Blob Storage를 사용하면 추가로 고려해야 할 운영 요소들이 있다.

20.1 이미지 접근 권한

Blob Storage를 사용하더라도 컨테이너를 public으로 열어두지는 않았다.

문의 이미지는 고객 정보나 내부 업무 화면을 포함할 수 있기 때문에, 단순 Blob URL만 알면 누구나 접근할 수 있는 구조는 피해야 했다.

그래서 실제 이미지 조회는 Blob URL 직접 접근이 아니라 백엔드 이미지 조회 API를 통해 처리했다.

본문 HTML의 img src에는 실제 Blob URL 대신 다음과 같은 백엔드 API URL이 들어간다.

/api/public-files/{fileId}/content?token={token}

CRM이나 커스텀 포탈에서 본문이 렌더링되면 브라우저는 이 API를 호출한다. 백엔드는 fileIdtoken을 검증한 뒤, 권한이 있는 요청에 대해서만 Blob Storage에서 이미지를 읽어 반환한다.

이 구조를 사용하면 Blob Storage는 private 상태를 유지할 수 있고, 프론트엔드나 HTML에 Storage Account Key, SAS Token, 실제 Blob URL을 직접 노출하지 않아도 된다.

20.2 token 관리

이미지 조회 API에는 token이 함께 전달된다.

다만 원본 token을 Dataverse에 그대로 저장하지 않고, 해시값만 저장하는 방식으로 구성했다. 백엔드는 이미지 요청이 들어올 때 요청 token을 같은 방식으로 해시하고, Dataverse 메타데이터에 저장된 token hash와 비교한다.

이 값이 일치할 때만 Blob Storage에서 이미지를 읽어 응답한다.

이 방식은 단순히 Blob URL을 숨기는 것보다 안전하다. Blob Storage가 private 상태로 유지되고, 이미지 조회 역시 token 검증을 통과해야 하기 때문이다.

다만 token이 URL query string에 포함되기 때문에 운영 시에는 몇 가지를 추가로 고려해야 한다.

브라우저 히스토리, 서버 접근 로그, 프록시 로그 등에 token이 남을 수 있다. 따라서 로그에 query string 전체가 남지 않도록 설정하거나, token 만료 정책을 두는 것이 좋다.

또한 token이 유출되었을 때를 대비해 token 재발급, 만료 처리, 이미지 비활성화 상태값 등을 함께 고려할 수 있다.

21.3 temp 이미지 정리

사용자가 이미지를 업로드했지만 문의 접수를 하지 않으면 temp 이미지만 남을 수 있다.

이런 이미지는 일정 시간이 지나면 정리해야 한다.

예를 들어 업로드 후 24시간 이상 commit되지 않은 이미지는 삭제 대상으로 처리할 수 있다.

21.4 실패 복구

commit 중간에 실패할 수 있다.

예를 들어 Blob 이동은 성공했지만 Dataverse 메타데이터 저장이 실패할 수 있다.

반대로 메타데이터는 생성됐지만 HTML 업데이트가 실패할 수도 있다.

이런 경우를 대비해 상태값과 오류 메시지를 남겨야 한다.

21.5 이미지 삭제 정책

서비스케이스가 삭제되거나 본문에서 이미지가 제거되었을 때 Blob 이미지도 삭제할 것인지 정해야 한다.

즉시 삭제할 수도 있고, 일정 기간 보관 후 삭제할 수도 있다.

업무 감사나 이력 관리가 필요하다면 삭제 정책은 더 신중하게 정해야 한다.

반응형