본문 바로가기
인턴

[Azure] 프론트엔드에서 Dataverse를 걷어내고 Azure Function Apps 백엔드로 위임하기

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

이 글은 특정 회사/고객사/운영 환경과 직접 연결될 수 있는 값들을 모두 블라인드 처리한 기술 정리 글입니다.
실제 환경명, 테넌트 ID, 클라이언트 ID, 시크릿, 리소스 그룹명, Dataverse 테이블의 일부 내부명, 도메인, 저장소명 등은 공개하지 않습니다.


들어가며

이번 작업은 단순히 API 몇 개를 추가한 작업이 아니었다.

 

기존에는 프론트엔드가 Dataverse, Blob Storage, CRM 리치텍스트 이미지, 포털 첨부파일, 토큰 발급 흐름을 너무 직접적으로 알고 있었다.

이전 포스트에서 이미지 개선 구조에서 잔존하는 문제점을 빨간 네모로 체크했다



프론트는 고객 포털 UI를 담당해야 하는데, 실제로는 내부 시스템의 API 구조와 인증 방식까지 상당 부분 떠안고 있었다.

그래서 이번 작업의 핵심 목표는 다음과 같았다.

프론트엔드는 우리 서비스 API만 호출한다.
Dataverse 토큰과 내부 저장소 접근 정보는 백엔드만 알고 있다.
백엔드는 Dataverse와 Blob Storage를 대신 호출하고, 프론트에는 필요한 결과만 반환한다.

즉, 기존 구조를 다음과 같이 바꾸는 작업이었다.

Before

Frontend
  ├─ CRM/Dataverse 토큰 발급 API 호출
  ├─ Dataverse API 또는 그에 가까운 구조를 직접 인식
  ├─ 이미지 URL/파일 URL을 직접 다룸
  └─ 여러 API 응답 형태를 프론트에서 억지로 정규화


After

Frontend
  └─ Pyro API 호출

Pyro API
  ├─ 자체 JWT 인증
  ├─ Dataverse 토큰 내부 발급
  ├─ Dataverse API 호출 대행
  ├─ Blob Storage 접근 대행
  ├─ 파일/이미지 권한 검증
  └─ 프론트에 포털용 JSON만 반환

이번 글에서는 이 전환 과정을 최대한 상세히 정리한다.

다만 회사 내부 시스템에 직접 연결될 수 있는 정보는 모두 아래처럼 치환한다.

실제 정보 블라인드 표기
실제 회사/고객사 이름 [회사명]
실제 Dataverse 환경 URL [DATAVERSE_ENV_URL]
실제 Function App URL [FUNCTION_APP_BASE_URL]
실제 Key Vault 이름 [KEY_VAULT_NAME]
실제 Storage Account 이름 [STORAGE_ACCOUNT_NAME]
실제 리소스 그룹명 [RESOURCE_GROUP_NAME]
실제 GitHub Organization [GITHUB_ORG]
실제 GitHub Repository [GITHUB_REPO]
실제 Client ID / Tenant ID [CLIENT_ID], [TENANT_ID]
실제 Secret 값 [SECRET]
실제 사용자/고객 데이터 [USER_DATA], [CUSTOMER_DATA]

1. 기존 구조에서 느낀 문제

처음 구조에서 가장 불편했던 점은 “프론트가 너무 많은 내부 정보를 알고 있다”는 것이었다.

고객 포털 프론트는 사실 다음 정도만 알면 된다.

1. 로그인한다.
2. 내 정보를 조회한다.
3. 내 서비스 케이스 목록을 본다.
4. 케이스 상세를 본다.
5. 새 문의를 등록한다.
6. 파일과 이미지를 업로드한다.
7. 첨부파일과 이미지를 조회한다.

하지만 기존 구조에서는 프론트가 다음과 같은 것들까지 알아야 했다.

- CRM/Dataverse 토큰 발급 흐름
- 토큰 API의 client/secret 파라미터
- Dataverse 쪽 필드명과 응답 구조
- CRM 리치텍스트 이미지 URL 패턴
- 파일 URL과 이미지 URL 처리 방식
- access token 만료 처리
- API별 응답 포맷 차이

이 상태에서는 프론트 코드가 점점 복잡해질 수밖에 없었다.

특히 문제였던 부분은 다음과 같다.

1.1 프론트가 내부 인증 모델에 의존함

기존에는 프론트가 Dataverse 접근에 가까운 토큰 구조를 다루고 있었다.
이 말은 곧 프론트가 내부 시스템의 인증 방식 변경에 취약하다는 뜻이다.

예를 들어 백엔드에서 Dataverse 토큰 발급 방식이 바뀌면, 프론트도 영향을 받는다.
Dataverse API 버전이 바뀌거나, 환경 URL이 바뀌거나, 토큰 발급 주체가 바뀌어도 프론트 수정이 필요해진다.

이건 구조적으로 좋지 않다.

1.2 API 응답 정규화가 프론트에 몰림

기존 프론트에는 여러 응답 형태를 처리하기 위한 코드가 있었다.

예를 들어 토큰 응답에서 access_token, accessToken, expires_in, expiresAt, expires_on 같은 다양한 포맷을 프론트에서 처리해야 했다.

이건 프론트가 “사용자 인터페이스”를 넘어서 “외부 시스템 통합 레이어” 역할까지 하고 있다는 뜻이다.

1.3 파일과 이미지의 보안 경계가 애매함

서비스 케이스 첨부파일, 인라인 이미지, CRM 리치텍스트 이미지가 각각 다른 방식으로 처리되고 있었다.

  • 서비스 케이스 첨부파일은 Dataverse file column 기반
  • 인라인 이미지는 Blob Storage + Dataverse 메타데이터 기반
  • CRM 리치텍스트 이미지는 msdyn_richtextfiles 기반
  • 기존 public 파일 URL은 token query 기반
  • 신규 보호 URL은 JWT 기반

이 구조를 프론트가 직접 다루면 유지보수가 어렵다.

그래서 이미지와 파일도 백엔드가 권한을 검증한 뒤 내려주는 구조가 필요했다.


2. 목표 구조

최종 목표는 명확했다.

클라이언트는 Dataverse를 직접 상대하지 않는다.
클라이언트는 Pyro API만 호출한다.

전체 구조는 다음과 같다.

Frontend
  |
  | 1. 로그인 요청
  v
Pyro API (Azure Functions)
  |
  | 2. Dataverse contact 조회
  | 3. 인증 성공 시 자체 JWT 발급
  v
Frontend
  |
  | 4. 이후 모든 요청에 Authorization: Bearer <Pyro JWT>
  v
Pyro API
  |
  | 5. 내부에서 Dataverse token 발급
  | 6. Dataverse / Blob Storage 호출
  v
Dataverse / Blob Storage

여기서 중요한 점은 Pyro JWT와 Dataverse access token은 완전히 다른 것이라는 점이다.

구분 사용 위치 목적
Pyro JWT 프론트 ↔ Pyro API 포털 사용자 인증
Dataverse access token Pyro API 내부 Dataverse API 호출

프론트는 Dataverse access token을 모른다.
Dataverse token은 오직 백엔드 내부에서만 생성되고 사용된다.


3. 인프라 구성

이번 백엔드는 Azure Functions 기반으로 구성했다.

전체 인프라 흐름은 다음과 같다.

GitHub Repository
  |
  | GitHub Actions OIDC
  v
Azure Function App
  |
  | Managed Identity
  v
Azure Key Vault
  |
  | secret reference
  v
Function App Environment Variables

Function App
  |
  | client credentials
  v
Dataverse

Function App
  |
  | connection string
  v
Azure Blob Storage

3.1 Azure Function App

백엔드는 Azure Functions Node.js 기반으로 구성했다.

프로젝트 구조는 Azure Functions의 전통적인 function.json + index.js 방식이다.

예시 구조는 다음과 같다.

pyro-api/
├── host.json
├── package.json
├── local.settings.json
├── util/
│   ├── authMiddleware.js
│   ├── contactAuth.js
│   ├── dataverseRequest.js
│   ├── getDataverseClientConfig.js
│   ├── getDataverseToken.js
│   ├── htmlContent.js
│   ├── jwt.js
│   ├── portalInlineImage.js
│   ├── portalMultipart.js
│   ├── serviceCaseRequest.js
│   └── serviceCases.js
│
├── auth_login/
├── auth_refresh/
├── auth_me/
├── auth_change_password/
├── service_cases/
├── service_case_detail/
├── service_case_file/
├── portal_inline_image/
├── portal_files_commit/
├── portal_file_content/
├── crm_richtext_image/
├── dataverse_health/
└── health_check/

각 API는 독립적인 Function 폴더를 갖고, 공통 로직은 util/ 아래로 분리했다.

3.2 Key Vault

민감한 값은 Function App 환경변수에 직접 넣지 않고 Key Vault에 저장했다.

Key Vault에는 다음 종류의 secret이 들어간다.

dataverse-tenant-id
dataverse-url
dataverse-client-id
dataverse-client-secret
azure-webapp-upload-storage
pyro-api-jwt-secret
pyro-api-refresh-token-secret

실제 값은 모두 블라인드 처리해야 한다.

Function App 환경변수에는 실제 값이 아니라 Key Vault reference를 넣는다.

CLIENT_SECRET_PYRO =
@Microsoft.KeyVault(SecretUri=https://[KEY_VAULT_NAME].vault.azure.net/secrets/dataverse-client-secret/[VERSION])

이 구조의 장점은 다음과 같다.

1. secret을 Function App 설정 화면에 직접 노출하지 않는다.
2. secret 변경 시 Key Vault에서 관리할 수 있다.
3. Function App은 Managed Identity로 필요한 secret만 읽는다.
4. GitHub나 코드 저장소에 secret이 들어가지 않는다.

 

3.3 Managed Identity

Function App이 Key Vault reference를 해석하려면 Managed Identity가 필요하다.

설정 흐름은 다음과 같다.

Function App
→ ID
→ 시스템 할당 관리 ID 활성화

그 다음 Key Vault에서 Function App의 Managed Identity에 권한을 부여한다.

Key Vault
→ 액세스 제어(IAM)
→ 역할 할당 추가
→ Key Vault Secrets User
→ Function App Managed Identity 선택

처음에 이 설정이 빠져 있었을 때 실제로 문제가 발생했다.
Function App 환경변수 값이 Key Vault secret 값으로 풀리지 않고, @Microsoft.KeyVault(...) 문자열 그대로 코드에 들어갔다.

그 결과 Dataverse token 발급 시 tenant 값으로 아래 같은 문자열이 전달되었다.

@microsoft.keyvault(secreturi=https:

당연히 Azure AD는 이것을 tenant로 인식할 수 없어서 인증 오류가 발생했다.

이 문제는 Managed Identity 활성화와 Key Vault 권한 부여 후 해결되었다.

3.4 GitHub Actions OIDC 배포

처음에는 Publish Profile 방식으로 GitHub Actions 배포를 붙이려고 했다.
하지만 Kudu/SCM 인증에서 401이 발생했다.

문제 메시지는 대략 다음과 같았다.

Failed to fetch Kudu App Settings.
Unauthorized (CODE: 401)

Publish Profile 방식은 SCM Basic Auth 설정, Kudu 인증 상태, publish profile 유효성 등에 영향을 받는다.

그래서 최종적으로는 OIDC 방식으로 전환했다.

GitHub Actions는 Azure에 직접 로그인하고, Function App에 배포한다.

워크플로우 핵심은 다음과 같다.

permissions:
  id-token: write
  contents: read

steps:
  - uses: actions/checkout@v4

  - uses: actions/setup-node@v4
    with:
      node-version: "20.x"
      cache: npm

  - run: npm ci

  - name: Azure Login
    uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

  - name: Deploy to Azure Functions
    uses: Azure/functions-action@v1
    with:
      app-name: [FUNCTION_APP_NAME]
      package: .

OIDC를 쓰기 위해 Azure App Registration에 Federated Credential을 추가했다.

issuer: https://token.actions.githubusercontent.com
subject: repo:[GITHUB_ORG]/[GITHUB_REPO]:ref:refs/heads/main
audience: api://AzureADTokenExchange

 


4. 환경 변수 정리

최종 환경변수는 크게 네 묶음으로 나뉜다.

4.1 JWT 관련

PYRO_API_JWT_SECRET
PYRO_API_REFRESH_TOKEN_SECRET
  • PYRO_API_JWT_SECRET: access token 서명/검증용
  • PYRO_API_REFRESH_TOKEN_SECRET: refresh token 서명/검증용
  • refresh 전용 secret이 없으면 access token secret으로 fallback 가능하도록 구현

4.2 Dataverse API URL

CLIENT_API_URL_PYRO

예시:

https://[DATAVERSE_ORG].crm[REGION].dynamics.com/api/data/v9.2/

블로그에서는 실제 URL을 공개하지 않는다.

4.3 Dataverse 인증 정보

CLIENT_TENANT_PYRO
CLIENT_RESOURCE_PYRO
CLIENT_ID_PYRO
CLIENT_SECRET_PYRO

이 값들은 백엔드가 Dataverse access token을 발급받기 위해 사용한다.

CLIENT_TENANT_PYRO   → Azure AD Tenant ID
CLIENT_RESOURCE_PYRO → Dataverse resource URL
CLIENT_ID_PYRO       → App Registration Client ID
CLIENT_SECRET_PYRO   → App Registration Client Secret

여기서 특히 CLIENT_SECRET_PYRO는 절대 코드나 GitHub에 들어가면 안 된다.

4.4 Blob Storage 관련

PORTAL_FILES_STORAGE_CONNECTION_STRING
AZURE_WEBAPP_UPLOAD_STORAGE
AZURE_UPLOAD_STORAGE
PORTAL_FILES_STORAGE_CONTAINER
AZURE_PORTAL_FILES_STORAGE_CONTAINER

Storage connection string은 Key Vault reference로 관리한다.

컨테이너 이름은 기본값을 둘 수 있다.

pyro-files

다만 실제 컨테이너 이름도 회사 내부명일 수 있으므로 블로그에는 [BLOB_CONTAINER_NAME] 정도로 치환한다.


5. 인증 시스템

이번 작업에서 인증 구조는 크게 바뀌었다.

5.1 로그인

프론트는 로그인 시 백엔드에 이메일과 비밀번호를 보낸다.

POST /api/auth/login
Content-Type: application/json

요청 형태는 다음과 같다.

{
  "email": "user@example.com",
  "password": "********",
  "client": "PYRO"
}

백엔드는 Dataverse의 contact를 조회하고, 인증이 성공하면 자체 JWT를 발급한다.

응답은 다음과 같다.

{
  "accessToken": "[ACCESS_TOKEN]",
  "refreshToken": "[REFRESH_TOKEN]",
  "tokenType": "Bearer",
  "expiresIn": 1800,
  "refreshTokenExpiresIn": 1209600,
  "user": {
    "id": "[CONTACT_ID]",
    "email": "user@example.com",
    "name": "User Name",
    "client": "PYRO",
    "role": "user"
  }
}

여기서 중요한 점은 다음이다.

응답에는 Dataverse access token이 없다.

백엔드가 발급하는 것은 Pyro API에서만 사용하는 자체 JWT다.

5.2 Access Token

Access Token은 짧은 수명을 가진다.

expiresIn: 1800 seconds

즉 30분이다.

payload는 대략 다음 구조다.

{
  "sub": "[CONTACT_ID]",
  "client": "PYRO",
  "role": "user",
  "tenantId": "PYRO",
  "tokenType": "access",
  "iss": "pyro-api",
  "aud": "pyro-client"
}

백엔드는 보호 API 호출 시 Authorization 헤더에서 이 토큰을 읽는다.

Authorization: Bearer [ACCESS_TOKEN]

5.3 Refresh Token

Refresh Token은 Access Token보다 길게 유지된다.

expiresIn: 14d

프론트는 API 호출 중 401이 발생하면 refresh API를 호출해 새 토큰을 받는다.

POST /api/auth/refresh

요청:

{
  "refreshToken": "[REFRESH_TOKEN]"
}

응답:

{
  "accessToken": "[NEW_ACCESS_TOKEN]",
  "refreshToken": "[NEW_REFRESH_TOKEN]",
  "tokenType": "Bearer",
  "expiresIn": 1800,
  "refreshTokenExpiresIn": 1209600
}

프론트는 새 토큰을 저장하고 원래 실패했던 요청을 재시도한다.

5.4 프론트의 토큰 관리

프론트는 토큰을 localStorage에 저장한다.

pyroAccessToken
pyroRefreshToken
pyroUser

API 클라이언트는 요청할 때마다 access token을 읽고 Authorization 헤더를 붙인다.

Authorization: `Bearer ${accessToken}`

401 응답을 받으면 refresh를 시도한다.

여기서 중요한 개선점은 동시 refresh 중복 방지다.

여러 요청이 동시에 401을 받으면 refresh 요청이 여러 번 나갈 수 있다.
이를 막기 위해 in-flight refresh promise를 공유한다.

let inflightRefresh: Promise<boolean> | null = null

이 구조 덕분에 동시에 여러 API가 실패해도 refresh는 한 번만 수행된다.


6. Dataverse 연동 레이어

Dataverse 연동은 공통 유틸로 분리했다.

6.1 getDataverseClientConfig

클라이언트별 설정을 읽는다.

getDataverseClientConfig("PYRO")

반환 예시:

{
  client: "PYRO",
  suffix: "PYRO",
  apiUrl: "[DATAVERSE_API_URL]"
}

인증 정보는 별도 함수에서 읽는다.

getDataverseAuthConfig("PYRO")

반환 예시:

{
  clientTenant: "[TENANT_ID]",
  clientResource: "[DATAVERSE_RESOURCE]",
  clientId: "[CLIENT_ID]",
  clientSecret: "[CLIENT_SECRET]"
}

실제 값은 모두 환경변수에서 읽는다.

6.2 getDataverseToken

백엔드 내부에서 Dataverse access token을 발급받는다.

const token = await getDataverseToken("PYRO")

이 토큰은 외부 응답에 포함하지 않는다.

사용 위치는 다음과 같다.

- Dataverse health check
- Contact 조회
- Service Case CRUD
- Dataverse file column upload/download
- CRM rich text image proxy

6.3 dataverseRequest

JSON 기반 Dataverse OData 호출은 dataverseRequest로 공통화했다.

await dataverseRequest({
  client: "PYRO",
  method: "GET",
  path: "new_q112s",
  query: {
    "$select": "new_q112id,new_name"
  }
})

내부에서는 다음을 자동 처리한다.

- Dataverse API base URL 조합
- access token 발급
- Authorization 헤더 부착
- OData 공통 헤더 부착
- 에러 메시지 정규화

기본 헤더는 다음과 같다.

Accept: application/json
Content-Type: application/json
OData-MaxVersion: 4.0
OData-Version: 4.0
Prefer: odata.include-annotations="*"
Authorization: Bearer [DATAVERSE_TOKEN]


7. 서비스 케이스 도메인

이번 구현의 핵심 기능은 서비스 케이스 API다.

서비스 케이스는 Dataverse의 특정 엔티티를 기준으로 구성했다.

블로그에서는 실제 엔티티명과 필드명을 모두 공개하지 않는 것이 안전하다.
따라서 이 글에서는 다음과 같이 표현한다.

실제 개념 블로그 표기
서비스 케이스 엔티티 [SERVICE_CASE_ENTITY]
서비스 케이스 기본키 [SERVICE_CASE_ID_FIELD]
케이스 번호 필드 [CASE_NUMBER_FIELD]
제목 필드 [TITLE_FIELD]
상태 필드 [STATUS_FIELD]
contact lookup [CONTACT_LOOKUP_FIELD]
회사 lookup [COMPANY_LOOKUP_FIELD]
file column 1~5 [FILE_SLOT_1] ~ [FILE_SLOT_5]

7.1 지원 API

서비스 케이스 관련 API는 다음과 같다.

GET    /api/service-cases
POST   /api/service-cases
GET    /api/service-cases/{id}
PATCH  /api/service-cases/{id}
GET    /api/service-cases/{id}/files/{slot}

7.2 목록 조회

목록 조회는 현재 로그인한 contact 기준으로 필터링된다.

현재 JWT의 sub = contactId
Dataverse service case의 contact lookup = contactId

즉 사용자는 자신에게 연결된 케이스만 볼 수 있다.

응답 예시는 다음과 같다.

{
  "items": [
    {
      "id": "[SERVICE_CASE_ID]",
      "number": "SC-0001",
      "title": "문의 제목",
      "status": {
        "code": 100000100,
        "key": "open"
      },
      "issueCategory": {
        "code": 100000000,
        "label": "CS"
      },
      "priority": {
        "code": 100000100,
        "label": "일반(Normal)"
      },
      "receivedAt": "2026-01-01T00:00:00Z",
      "scheduledEndAt": null,
      "companyId": "[COMPANY_ID]",
      "createdBy": "[USER_ID]"
    }
  ],
  "nextCursor": "[CURSOR_OR_NULL]"
}

7.3 상태 매핑

서비스 케이스 상태는 코드와 key로 나누어 반환한다.

{
  "code": 100000100,
  "key": "open"
}

예시 상태는 다음과 같다.

key 의미
open 접수
progress 처리중
wait 대기
pending 완료 승인 대기
close 종료

특정 상태에서는 고객이 더 이상 수정할 수 없도록 제한했다.

wait
pending
close

이 상태의 케이스는 PATCH 요청이 거부된다.

7.4 검색과 정렬

목록 조회는 다음 쿼리 파라미터를 지원한다.

pageSize
cursor
status
keyword
$orderby
$select

예시:

GET /api/service-cases?pageSize=10&status=open,progress&keyword=printer&$orderby=receivedAt desc

프론트에서는 UI 필드명을 백엔드/Dataverse 필드명으로 매핑한다.

number      → [CASE_NUMBER_FIELD]
title       → [TITLE_FIELD]
receivedAt  → [RECEIVED_AT_FIELD]
status      → [STATUS_FIELD]
priority    → [PRIORITY_FIELD]

7.5 Cursor 기반 페이지네이션

기존 offset 기반 페이지네이션을 쓰지 않고 cursor 기반으로 전환했다.

이유는 Dataverse OData의 $skip이 일반적인 REST API처럼 안정적으로 동작하지 않기 때문이다.

최종적으로는 FetchXml + paging cookie 기반 페이지네이션을 사용했다.

흐름은 다음과 같다.

1. 첫 페이지 요청
2. Dataverse FetchXml 조회
3. 응답 annotation에서 paging cookie 획득
4. paging cookie + pageNumber를 base64url cursor로 변환
5. 프론트에 nextCursor 반환
6. 다음 페이지 요청 시 cursor 전달

응답 구조:

{
  "items": [],
  "nextCursor": "base64url-encoded-cursor"
}

프론트는 전체 total count를 표시하지 않고 다음/이전 버튼을 cursor stack으로 관리한다.


8. 서비스 케이스 생성/수정

8.1 생성 API

POST /api/service-cases

지원 Content-Type은 두 가지다.

application/json
multipart/form-data

파일이 없으면 JSON으로 보낸다.

{
  "title": "문의 제목",
  "issueCategory": 100000000,
  "priority": 100000100,
  "productQuantityHtml": "<p>제품 정보</p>",
  "requestHtml": "<p>문의 내용</p>"
}

파일이 있으면 FormData로 보낸다.

const formData = new FormData()
formData.append("title", title)
formData.append("issueCategory", String(issueCategory))
formData.append("priority", String(priority))
formData.append("productQuantityHtml", productQuantityHtml)
formData.append("requestHtml", requestHtml)
files.forEach(file => formData.append("files", file))

8.2 서버가 자동으로 채우는 값

클라이언트가 넣지 않고 서버가 채우는 값이 있다.

- 접수일시
- contact lookup
- 회사 lookup

즉 프론트가 회사 ID나 contact ID를 보내지 않는다.
백엔드는 JWT의 contact ID를 기준으로 contact를 조회하고, 거기서 연결된 회사 정보를 가져와 서비스 케이스에 바인딩한다.

이 구조 덕분에 프론트가 임의의 contact/company ID를 조작하기 어렵다.

8.3 수정 API

PATCH /api/service-cases/{id}

수정 가능한 필드는 제한했다.

- 제목
- 이슈카테고리
- 우선순위
- 제품/수량 HTML
- 문의 내용 HTML
- 첨부파일 삭제/재업로드

수정할 수 없는 필드는 다음과 같다.

- 상태
- 완료 여부
- 담당 엔지니어
- 처리 내용

고객 포털에서 고객이 바꿀 수 있는 범위를 제한하기 위해서다.

8.4 소유권 검증

상세 조회와 수정은 모두 소유권 검증을 수행한다.

JWT의 contactId == service case의 contact lookup

일치하지 않으면 404를 반환한다.

여기서 403이 아니라 404를 반환하는 이유는 리소스 존재 여부 자체를 감추기 위해서다.

존재하지만 권한 없음 → 404
실제로 없음 → 404

이렇게 처리하면 타인의 케이스 ID를 추측해도 존재 여부를 알기 어렵다.


9. Dataverse File Column 첨부파일 처리

서비스 케이스 첨부파일은 Dataverse file column을 사용한다.

최대 5개 슬롯을 사용한다.

[FILE_SLOT_1]
[FILE_SLOT_2]
[FILE_SLOT_3]
[FILE_SLOT_4]
[FILE_SLOT_5]

9.1 업로드

파일 업로드는 Dataverse file column endpoint에 PATCH 요청을 보내는 방식이다.

개념적으로는 다음과 같다.

PATCH [DATAVERSE_API_URL]/[SERVICE_CASE_ENTITY]([SERVICE_CASE_ID])/[FILE_SLOT]
Authorization: Bearer [DATAVERSE_TOKEN]
Content-Type: application/octet-stream
x-ms-file-name: example.pdf
If-None-Match: null

중요한 점은 If-None-Match: null 헤더다.

이 헤더가 없으면 Dataverse file column 업로드가 실패할 수 있다.

9.2 파일명 인코딩

파일명이 ASCII만 포함하면 x-ms-file-name 헤더에 넣는다.

하지만 한글 파일명처럼 non-ASCII 문자가 포함되면 헤더에 직접 넣지 않고 query parameter로 인코딩한다.

ASCII 파일명
→ x-ms-file-name 헤더

한글/비 ASCII 파일명
→ ?x-ms-file-name=encodeURIComponent(fileName)

이 처리를 하지 않으면 파일명이 깨지거나 Dataverse가 잘못 저장할 수 있다.

9.3 삭제

파일 삭제는 file column endpoint에 DELETE를 보낸다.

DELETE [DATAVERSE_API_URL]/[SERVICE_CASE_ENTITY]([SERVICE_CASE_ID])/[FILE_SLOT]

필드를 null로 PATCH하는 방식이 아니라 file column 전용 삭제 endpoint를 사용한다.

9.4 재업로드

기존 파일이 있는 슬롯에 다시 업로드하려면 주의가 필요하다.

Dataverse file column은 동일 슬롯에 파일이 남아있는 상태에서 다시 업로드하면 동시 업로드 관련 오류가 발생할 수 있다.

그래서 재업로드는 다음 순서로 처리했다.

1. 덮어쓸 슬롯 계산
2. 기존 슬롯 DELETE
3. 실제로 슬롯이 비었는지 polling
4. 비워진 뒤 새 파일 업로드

polling은 짧은 간격으로 여러 번 수행한다.

300ms 간격
최대 10회

9.5 다운로드

첨부파일 다운로드는 다음 API로 처리한다.

GET /api/service-cases/{id}/files/{slot}
Authorization: Bearer [ACCESS_TOKEN]

백엔드는 다음을 수행한다.

1. JWT 검증
2. 서비스 케이스 소유권 검증
3. Dataverse file column $value 조회
4. 바이너리 그대로 응답

Azure Functions에서 바이너리 응답을 제대로 처리하려면 function.jsondataType: "binary" 설정이 필요하다.

응답 헤더는 대략 다음과 같다.

Content-Type: application/octet-stream
Content-Disposition: attachment; filename*=UTF-8''encoded-file-name
Cache-Control: private, no-store
X-Content-Type-Options: nosniff

 


10. 인라인 이미지 시스템

서비스 케이스 첨부파일과 별개로, 리치텍스트 에디터 내부 이미지를 처리하는 시스템도 구현했다.

10.1 왜 별도 시스템이 필요한가

서비스 케이스 첨부파일은 “첨부파일”이다.
하지만 리치텍스트 에디터 이미지는 HTML 내부에 들어가는 이미지다.

예를 들면 사용자가 문의 내용에 아래처럼 이미지를 넣을 수 있다.

<p>아래 화면에서 오류가 발생합니다.</p>
<img src="..." />

이 이미지는 단순 첨부파일이 아니라 HTML의 일부다.

따라서 저장 방식도 다르게 가져갔다.

인라인 이미지
→ Azure Blob Storage에 바이너리 저장
→ Dataverse 메타데이터 엔티티에 파일 정보 저장
→ HTML에는 백엔드 보호 URL 저장

10.2 temp → active 구조

인라인 이미지는 업로드와 저장 확정 시점이 다르다.

사용자가 에디터에서 이미지를 올렸지만, 실제 문의 저장을 누르지 않을 수도 있다.

그래서 이미지 상태를 두 단계로 나눴다.

1. 업로드 직후: temp
2. 서비스 케이스 생성/수정 후 commit: active

Blob 경로도 다르게 관리한다.

temp:
customer-portal/temp/{uploadSessionId}/{editorKey}/{fileName}

active:
customer-portal/cases/{serviceCaseId}/customer/{editorKey}/{fileName}

10.3 인라인 이미지 업로드 API

POST /api/portal/inline-image
Content-Type: multipart/form-data

요청 필드:

uploadSessionId
editorKey
file

허용 MIME:

image/png
image/jpeg
image/webp
image/gif

최대 크기:

5MB

응답은 다음과 같다.

{
  "fileId": "[FILE_ID]",
  "src": "[PROTECTED_IMAGE_URL]",
  "protectedSrc": "[PROTECTED_IMAGE_URL]",
  "blobPath": "[BLOB_PATH]",
  "mimeType": "image/png",
  "sizeBytes": 12345,
  "originalFileName": "image.png"
}

현재 구조에서는 srcprotectedSrc 모두 보호 URL을 가리킨다.

10.4 에디터에서의 문제

보호 URL은 JWT가 필요하다.

그런데 일반 <img src="...">는 Authorization 헤더를 마음대로 붙이기 어렵다.

그래서 프론트 에디터에서는 다음 전략을 사용했다.

1. 서버에 이미지 업로드
2. 서버가 protected URL 반환
3. 프론트는 로컬 Blob URL 생성
4. 에디터 img.src에는 Blob URL 사용
5. data-protected-src에 서버 protected URL 보관

HTML 예시:

<img
  src="blob:http://localhost:5173/..."
  data-protected-src="https://[FUNCTION_APP]/api/portal/files/[FILE_ID]/content"
/>

사용자가 화면에서 볼 때는 Blob URL이라 즉시 이미지가 보인다.

하지만 서버에 제출하기 전에는 data-protected-src를 다시 src로 복원한다.

blob URL → protected URL

10.5 restoreProtectedSrcForSubmit

프론트에는 제출 전 HTML을 정리하는 함수가 있다.

restoreProtectedSrcForSubmit(html)

이 함수는 HTML 안의 이미지를 찾아서:

img.src = img.data-protected-src
data-protected-src 제거

를 수행한다.

그 결과 서버에 저장되는 HTML에는 Blob URL이 아니라 protected URL이 들어간다.

10.6 commit API

서비스 케이스 생성/수정 후에는 commit API를 호출한다.

POST /api/portal/files/commit

요청:

{
  "serviceCaseId": "[SERVICE_CASE_ID]",
  "uploadSessionId": "[UPLOAD_SESSION_ID]"
}

백엔드는 서비스 케이스 HTML에서 이미지 URL을 찾는다.

/api/portal/files/{fileId}/content

그 다음 해당 fileId의 메타데이터를 조회하고, temp 상태 이미지라면 다음을 수행한다.

1. temp blob 읽기
2. active 경로로 복사
3. Dataverse 메타데이터 업데이트
4. temp blob 삭제 시도
5. 상태를 active로 변경

commit이 성공하면 이미지는 서비스 케이스에 귀속된다.


11. 보호 이미지 조회

보호된 인라인 이미지는 다음 API로 조회한다.

GET /api/portal/files/{fileId}/content
Authorization: Bearer [ACCESS_TOKEN]

백엔드는 다음을 검증한다.

1. JWT가 유효한가
2. fileId가 유효한가
3. 파일 메타데이터가 존재하는가
4. 파일 상태가 허용 상태인가
5. 파일이 연결된 서비스 케이스를 현재 사용자가 소유하는가
6. Blob path가 존재하는가

검증이 끝나면 Blob Storage에서 바이너리를 읽어서 반환한다.

응답 헤더는 다음과 같이 설정한다.

Content-Type: image/png
Cache-Control: private, no-store
X-Content-Type-Options: nosniff

11.1 RichTextViewer에서의 처리

상세 조회 화면에서는 HTML을 그대로 렌더링하면 보호 이미지가 보이지 않을 수 있다.
Authorization 헤더가 필요하기 때문이다.

그래서 프론트는 HTML을 렌더링한 뒤 DOM에서 이미지 태그를 찾아, 보호 이미지 URL을 직접 fetch한다.

1. HTML 렌더링
2. img[src] 수집
3. 보호 이미지 URL인지 판단
4. Authorization 헤더로 fetch
5. blob 응답 수신
6. URL.createObjectURL(blob)
7. img.src를 blob URL로 교체

이미지 로딩 중에는 shimmer skeleton을 보여준다.


12. CRM 리치텍스트 이미지 프록시

CRM/Power Apps에서 엔지니어가 처리 내용에 이미지를 삽입하면, Dataverse의 msdyn_richtextfiles 계열 이미지 URL이 HTML에 들어간다.

이 URL은 Dataverse 직접 접근 URL이라 프론트에서 그대로 열 수 없다.
Authorization 헤더가 필요하기 때문이다.

그래서 백엔드에 프록시 API를 추가했다.

GET /api/richtextfiles/{attachmentId}/image
Authorization: Bearer [ACCESS_TOKEN]

프론트는 CRM 이미지 URL을 발견하면 백엔드 프록시 URL로 변환한다.

/api/data/v9.2/msdyn_richtextfiles({id})/msdyn_imageblob/$value
↓
/api/richtextfiles/{id}/image

백엔드는 Dataverse token을 내부에서 붙여 이미지를 가져오고, 바이너리로 반환한다.

여기서도 dataType: "binary" 설정이 중요하다.
이 설정이 없으면 이미지 바이트가 깨질 수 있다.


13. HTML Sanitization

리치텍스트 HTML은 사용자 입력이다.

따라서 저장 전에 XSS 방어가 필요하다.

이번 구현에서는 sanitize-html을 사용해 HTML을 정리했다.

처리 대상은 다음과 같다.

- 제품/수량 HTML
- 문의 내용 HTML
- 필요 시 처리 내용 HTML

허용하는 태그와 속성은 제한한다.

예를 들어 허용하는 태그:

p
br
strong
em
u
ul
ol
li
a
img
table
thead
tbody
tr
td
th
span
div

차단해야 하는 대표적인 입력:

<script>alert(1)</script>
<img src="x" onerror="alert(1)" />
<a href="javascript:alert(1)">click</a>

sanitize 이후에는 위험한 script, event handler, javascript URI가 제거된다.

이미지의 경우 src, alt, title, width, height 정도만 허용한다.

추가로 레거시 이미지 URL을 보호 URL로 정규화하는 처리도 포함했다.

/public-files/{id}/content
→ /portal/files/{id}/content

 


14. 프론트 서비스 레이어 정리

프론트에서도 구조를 크게 정리했다.

기존에는 다음 파일들이 섞여 있었다.

mockData.ts
inquiryService.ts
dataverseInquiryService.ts

이 파일들은 각각 더미 데이터, 구버전 문의 API, Dataverse 직접 호출/이미지 처리 등이 섞여 있었다.

최종적으로는 아래 구조로 단순화했다.

src/services/
  apiClient.ts
  authService.ts
  serviceCaseService.ts
  inlineImageUploadService.ts

14.1 apiClient.ts

공통 HTTP 클라이언트다.

역할:

- API base URL 조합
- access token 조회
- Authorization 헤더 부착
- 401 발생 시 refresh token으로 자동 갱신
- refresh 성공 시 원래 요청 재시도
- 보호 이미지 blob fetch

핵심 흐름:

apiRequest()
  → access token 붙여 요청
  → 401이면 tryRefreshToken()
  → refresh 성공하면 재요청
  → 실패하면 토큰 삭제 후 로그인 필요 에러

14.2 authService.ts

인증 관련 서비스다.

역할:

- login
- logout
- changePassword
- 사용자 정보 저장/조회

로그인 성공 시:

saveTokens(accessToken, refreshToken)
storeUser(user)

14.3 serviceCaseService.ts

서비스 케이스 도메인 API를 담당한다.

역할:

- listServiceCases
- getServiceCase
- createServiceCase
- updateServiceCase
- 서버 응답을 UI 타입으로 변환
- 파일 업로드 FormData 처리
- 첨부파일 다운로드 URL 조립

서버 응답의 상태 객체를 UI가 쓰는 형태로 변환한다.

status: { code: 100000100, key: "open" }
→
status: "open"

14.4 inlineImageUploadService.ts

인라인 이미지 업로드와 commit을 담당한다.

uploadInlineImage()
commitUploadedInlineImages()

이제 직접 fetch를 쓰지 않고 apiRequest를 사용한다.


15. 프론트 화면 변경

15.1 문의 목록

문의 목록은 cursor 기반 페이지네이션으로 바뀌었다.

이전에는 전체 건수와 페이지 번호를 계산했다.

1-10 / 47건

하지만 cursor 기반에서는 전체 건수를 알 수 없다.

그래서 현재 UI는 다음처럼 단순해졌다.

페이지 1
이전 / 다음

필터도 단순화했다.

접수번호 / 제목 검색 → keyword 하나로 통합
상태 필터 → open, progress, wait, pending, close

목록 컬럼도 정리했다.

접수번호
제목
이슈카테고리
우선순위
접수일
상태

기존 회사명, 담당자 컬럼은 제거했다.

15.2 문의 작성

문의 작성 폼도 크게 줄었다.

이전에는 부서, 접수자, 담당자, 접수일, 방법, 고객정보 등을 입력했다.

하지만 이제 대부분은 백엔드가 contact 기준으로 처리한다.

최종 입력 항목은 다음 정도다.

제목
이슈카테고리
우선순위
제품/수량
문의 내용
첨부파일

이렇게 줄이면서 사용자는 실제 문의 작성에 필요한 내용만 입력하면 된다.

15.3 상세 모달

상세 화면은 다음 섹션 중심으로 정리했다.

1. 제품/수량
2. 문의 내용
3. 처리 내용
4. 첨부파일

고객 정보 섹션은 제거했다.
로그인 사용자의 contact 정보는 이미 서버가 알고 있고, 포털 사용자가 매번 확인할 필요가 적기 때문이다.

15.4 비밀번호 변경 다이얼로그

상단 네비게이션 사용자 메뉴에 비밀번호 변경 기능을 추가했다.

구성:

현재 비밀번호
새 비밀번호
새 비밀번호 확인

성공 시 일정 시간 후 로그아웃 처리한다.

이유는 refresh token이 이전 인증 상태를 기준으로 발급되었기 때문이다.


16. CORS 처리

프론트 개발 서버는 보통 다음 Origin에서 실행된다.

http://localhost:5173

Azure Function App에서 CORS에 이 origin을 추가했다.

운영에서는 실제 프론트 도메인만 추가해야 한다.

https://[FRONTEND_DOMAIN]

주의할 점은 다음이다.

Access-Control-Allow-Origin: *

을 무조건 쓰는 방식은 피한다.

현재 구조는 Bearer Token 기반이기 때문에 쿠키 credentials는 필수는 아니다.
다만 로컬 설정에서는 local.settings.json의 Host.CORS 설정이 적용될 수 있다.


17. 배포 흐름

최종 배포 흐름은 다음과 같다.

개발자
  |
  | git push main
  v
GitHub Actions
  |
  | OIDC login
  v
Azure
  |
  | Function App deploy
  v
Pyro API 배포 완료

GitHub Actions는 다음 단계를 수행한다.

1. checkout
2. Node.js setup
3. npm ci
4. Azure Login via OIDC
5. Azure Functions deploy

Publish Profile 방식이 아니라 OIDC 방식이므로, GitHub에는 publish profile XML을 저장하지 않는다.

GitHub Secrets에는 다음 값만 둔다.

AZURE_CLIENT_ID
AZURE_TENANT_ID
AZURE_SUBSCRIPTION_ID

 


18. 테스트한 시나리오

수동으로 확인한 주요 시나리오는 다음과 같다.

18.1 인증

- 로그인 성공
- 로그인 실패
- access token 저장
- 보호 API 요청 시 Authorization 헤더 부착
- access token 만료 시 refresh 요청
- refresh 성공 후 원 요청 재시도
- JWT 없이 /auth/me 호출 시 401
- JWT 포함 /auth/me 호출 시 성공

18.2 서비스 케이스

- 목록 조회
- 상세 조회
- 소유권 검증
- 생성
- 수정
- 수정 불가 상태 차단
- cursor 기반 다음 페이지 조회
- 상태 필터
- keyword 검색
- 정렬

18.3 첨부파일

- 파일 첨부 생성
- 파일 다운로드
- 파일 삭제
- 파일 재업로드
- 한글 파일명 다운로드
- 파일 크기 조회

18.4 인라인 이미지

- 이미지 업로드
- 에디터 내 Blob URL 미리보기
- 제출 전 protected URL 복원
- service case 생성 후 commit
- protected image fetch
- RichTextViewer에서 blob URL 렌더링

18.5 CRM 리치텍스트 이미지

- CRM rich text HTML 내 msdyn_richtextfiles URL 감지
- 백엔드 프록시 URL로 변환
- Authorization 헤더로 이미지 fetch
- 화면 렌더링

 


19. 작업하면서 겪은 주요 문제와 해결

19.1 Key Vault reference가 안 풀리는 문제

증상:

tenant 값에 @Microsoft.KeyVault(...) 문자열이 그대로 들어감

원인:

Function App Managed Identity가 꺼져 있거나,
Key Vault에 Function App 권한이 없음

해결:

1. Function App 시스템 할당 Managed Identity 활성화
2. Key Vault IAM에서 Key Vault Secrets User 역할 부여
3. Function App 재시작

19.2 GitHub Actions Publish Profile 401

증상:

Failed to fetch Kudu App Settings.
Unauthorized (CODE: 401)

해결:

Publish Profile 방식 대신 OIDC 방식으로 전환

19.3 CORS 오류

증상:

No 'Access-Control-Allow-Origin' header is present

해결:

Azure Function App CORS에 http://localhost:5173 추가

운영에서는 실제 프론트 도메인만 추가한다.

19.4 Authorization 헤더 누락으로 401

증상:

로그인은 성공했는데 /api/service-cases에서 401

원인:

로그인 응답의 accessToken을 저장했지만,
apiClient가 service-cases 요청에 Authorization 헤더를 붙이지 않음

해결:

apiClient에서 모든 보호 API 요청에 Authorization: Bearer <accessToken> 부착

19.5 Dataverse File Column 재업로드 문제

증상:

기존 파일 슬롯에 새 파일을 올릴 때 충돌

해결:

1. 기존 파일 삭제
2. 슬롯이 비워졌는지 polling
3. 새 파일 업로드

19.6 publicSrc 길이 제한 문제

증상:

new_txt_publicsrc 필드 길이 제한 초과

원인:

필드 최대 길이보다 긴 URL 저장

최종 해결은 구현 상태에 맞춰 정리한다.

- 긴 URL 저장을 피하거나
- 보호 URL을 상대 경로로 다루거나
- 필드 길이를 조정하거나
- URL을 런타임 생성하는 방식으로 전환

블로그에 실제 내부 필드명이나 전체 URL은 공개하지 않는다.


20. 보안적으로 좋아진 점

이번 작업에서 가장 중요한 변화는 보안 경계가 명확해졌다는 점이다.

20.1 Dataverse Token 비노출

이전에는 클라이언트가 Dataverse token에 가까운 값을 다룰 여지가 있었다.
현재는 백엔드 내부에서만 Dataverse token을 사용한다.

Frontend
→ Pyro JWT만 보유

Backend
→ Dataverse token 보유

20.2 Secret의 서버 이동

프론트 환경변수에서 secret 성격의 값이 사라졌다.

프론트에는 API base URL 정도만 있으면 된다.

VITE_API_BASE_URL=https://[FUNCTION_APP_BASE_URL]/api

민감 정보는 모두 Key Vault와 Function App 환경변수로 이동했다.

20.3 권한 검증

서비스 케이스와 파일 조회 시 contact 기반 소유권 검증을 수행한다.

JWT contactId == resource owner contactId

일치하지 않으면 404를 반환한다.

20.4 이미지/파일 프록시

Blob URL이나 Dataverse file URL을 직접 공개하지 않고, 백엔드가 검증 후 바이너리를 내려준다.

Client
→ /api/service-cases/{id}/files/{slot}

Backend
→ 소유권 검증
→ Dataverse file column 조회
→ binary response

20.5 GitHub 배포 인증 개선

Publish Profile 대신 OIDC를 사용해 배포했다.

GitHub Actions
→ OIDC
→ Azure Login
→ Function App Deploy

XML publish profile을 GitHub Secret에 저장하지 않아도 된다.


 

21. 최종적으로 얻은 구조

이번 작업을 통해 얻은 구조는 다음과 같다.

Frontend
  ├─ authService
  ├─ apiClient
  ├─ serviceCaseService
  └─ inlineImageUploadService

Backend (Azure Functions)
  ├─ auth_login
  ├─ auth_refresh
  ├─ auth_me
  ├─ auth_change_password
  ├─ service_cases
  ├─ service_case_detail
  ├─ service_case_file
  ├─ portal_inline_image
  ├─ portal_files_commit
  ├─ portal_file_content
  ├─ crm_richtext_image
  └─ dataverse_health

Dataverse
  ├─ Contact
  ├─ Service Case Entity
  ├─ File Columns
  └─ Rich Text Files

Azure
  ├─ Function App
  ├─ Key Vault
  ├─ Blob Storage
  └─ Application Insights

GitHub
  └─ Actions OIDC Deploy

 


22. 마무리

이번 작업의 핵심은 기능 추가보다 책임 분리였다.

기존에는 프론트가 내부 시스템을 너무 많이 알고 있었다.
Dataverse token, 파일 URL, 이미지 URL, CRM 응답 구조, 토큰 만료 처리 같은 것들이 프론트에 섞여 있었다.

이번 전환 이후 구조는 훨씬 단순해졌다.

프론트는 Pyro API만 안다.
Pyro API는 Dataverse와 Blob Storage를 책임진다.
민감한 인증 정보는 Key Vault와 Function App 내부에만 있다.
파일과 이미지는 백엔드가 권한 검증 후 내려준다.

결과적으로 다음을 얻었다.

- Dataverse token 외부 노출 제거
- 자체 JWT 기반 인증
- refresh token 기반 세션 유지
- 서비스 케이스 CRUD
- Dataverse file column 첨부 처리
- 인라인 이미지 temp/commit 구조
- CRM rich text image proxy
- GitHub Actions OIDC 배포
- Key Vault 기반 secret 관리

아직 운영 품질을 더 높이기 위한 과제는 남아 있다.
하지만 구조적으로는 이제 고객 포털 프론트가 Dataverse를 직접 상대하지 않고, 백엔드 API 계층을 통해 안전하게 통신하는 형태가 되었다.

이 전환이 가장 큰 성과였다.

반응형