> ## Documentation Index
> Fetch the complete documentation index at: https://docs.notifly.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Braze Catalog에서 마이그레이션하기

> Braze Catalog API 호출과 데이터를 노티플라이 Catalog REST API로 옮길 때 필요한 차이, 변환 규칙, 전환 절차를 설명합니다.

Braze Catalog와 노티플라이 Catalog는 카탈로그·아이템·필드·셀렉션의 경로와 요청 구조가 대부분 같습니다. 다만 **기본 URL과 인증**, **프로젝트 경로**, **응답 감싸기 형식**, **동기·비동기 상태 코드**, **데이터 분류 선언**은 다릅니다. URL만 바꿔 바로 대체할 수는 없습니다.

이 문서는 2026년 8월 4일 기준 Braze 공식 Catalog API와 노티플라이 Catalog REST API를 비교합니다. 실제 전환에서는 현재 사용 중인 Braze 클러스터의 REST 엔드포인트와 최신 Braze 문서도 함께 확인하세요.

## 먼저 확인할 차이

| 항목                               | Braze Catalog API                                                                                     | 노티플라이 Catalog API                                 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| 기본 URL                           | 워크스페이스 클러스터별 REST 엔드포인트                                                                               | `https://api.notifly.tech`                        |
| 인증                               | REST API key를 Bearer token으로 사용                                                                       | Access Key·Secret Key로 발급한 1시간 유효 Bearer token 사용 |
| 프로젝트 식별                          | API key의 workspace로 결정                                                                                | 모든 경로에 `/v1/projects/{projectId}` 포함              |
| 데이터 분류                           | 생성 요청에 별도 필드 없음                                                                                       | `data_classification: "non_personal"` 필수          |
| 응답                               | `message`, `catalogs`, `items`, `errors` 중심                                                           | `{ "data": ..., "error": ... }` 감싸기 형식            |
| Catalog·아이템 삭제, 일괄 아이템·필드·셀렉션 쓰기 | 엔드포인트마다 동기·비동기 분류가 다르지만 성공 시 주로 `202`                                                                 | 요청 안에서 처리하고 `200` 반환                              |
| Catalog 필드 수                     | REST API는 최대 500개. CSV 업로드는 최대 1,000열                                                                 | API Catalog는 `id` 포함 최대 500개                      |
| 아이템 페이지                          | 50개, `Link` header와 cursor                                                                            | 50개, `Link` header와 `data.next_cursor`            |
| Selection 입력                     | `external_id`, `source` 포함                                                                            | 두 필드를 받지 않으며 `name`으로 식별                          |
| 전용 호출량 제한                        | 동기 Catalog·item은 공용 50 req/min, 비동기 field/selection은 별도 공용 50 req/min, 비동기 일괄 item은 공용 16,000 req/min | Braze와 같은 엔드포인트별 공용 제한은 공개 계약에 없음                 |

<Warning>
  Braze에 저장한 데이터에 개인 이름, 이메일, 전화번호, 외부 유저 ID 같은 개인 식별 정보가 있다면 그대로 옮기지 마세요. 노티플라이 API 카탈로그는 비개인 참조 데이터만 지원합니다.
</Warning>

## Endpoint 바꾸기

Braze path 앞에 노티플라이 프로젝트 prefix를 붙입니다.

```text theme={null}
Braze:   https://{BRAZE_REST_ENDPOINT}/catalogs/{catalog_name}/items
Notifly: https://api.notifly.tech/v1/projects/{projectId}/catalogs/{catalogName}/items
```

경로의 `catalog_name`, `item_id`, `field_name`은 노티플라이에서 `catalogName`, `itemId`, `fieldName`으로 표현되지만 실제 URL 값과 역할은 같습니다.

### 카탈로그

| 작업 | Method와 suffix                   | 노티플라이 성공 상태 |
| -- | -------------------------------- | ----------- |
| 목록 | `GET /catalogs`                  | `200`       |
| 생성 | `POST /catalogs`                 | `201`       |
| 삭제 | `DELETE /catalogs/{catalogName}` | `200`       |

### 아이템

| 작업                  | Method와 suffix                                  | 노티플라이 성공 상태 |
| ------------------- | ----------------------------------------------- | ----------- |
| 페이지 목록              | `GET /catalogs/{catalogName}/items`             | `200`       |
| 단건 조회               | `GET /catalogs/{catalogName}/items/{itemId}`    | `200`       |
| 단건 생성               | `POST /catalogs/{catalogName}/items/{itemId}`   | `201`       |
| 단건 전체 교체·upsert     | `PUT /catalogs/{catalogName}/items/{itemId}`    | `200`       |
| 단건 부분 수정            | `PATCH /catalogs/{catalogName}/items/{itemId}`  | `200`       |
| 단건 삭제               | `DELETE /catalogs/{catalogName}/items/{itemId}` | `200`       |
| 최대 50개 생성           | `POST /catalogs/{catalogName}/items`            | `200`       |
| 최대 50개 전체 교체·upsert | `PUT /catalogs/{catalogName}/items`             | `200`       |
| 최대 50개 부분 수정        | `PATCH /catalogs/{catalogName}/items`           | `200`       |
| 최대 50개 삭제           | `DELETE /catalogs/{catalogName}/items`          | `200`       |

`PUT`은 전달한 아이템의 전체 값을 교체하며, 아이템이 없으면 만듭니다. `PATCH`는 기존 아이템의 전달한 필드만 바꾸며 없는 ID는 `404`를 반환합니다. 배열 필드에는 Braze와 같은 `$add`, `$remove` 연산을 사용할 수 있습니다.

```json theme={null}
{
  "items": [
    {
      "id": "sku-001",
      "tags": {
        "$add": ["sale"],
        "$remove": ["new"]
      }
    }
  ]
}
```

### 필드와 셀렉션

| 작업     | Method와 suffix                                              | 노티플라이 성공 상태 |
| ------ | ----------------------------------------------------------- | ----------- |
| 필드 추가  | `POST /catalogs/{catalogName}/fields`                       | `200`       |
| 필드 삭제  | `DELETE /catalogs/{catalogName}/fields/{fieldName}`         | `200`       |
| 셀렉션 생성 | `POST /catalogs/{catalogName}/selections`                   | `200`       |
| 셀렉션 삭제 | `DELETE /catalogs/{catalogName}/selections/{selectionName}` | `200`       |

노티플라이 셀렉션 생성 요청에서는 Braze의 `external_id`와 `source`를 제거합니다. 필터 값에는 `null`을 사용할 수 없으며, 필드 타입별 허용 연산자는 [Catalog REST API 시작하기](/ko/api-reference/catalogs/getting-started#셀렉션-필터)를 따릅니다.

```diff theme={null}
 {
   "selection": {
     "name": "sale-products",
     "description": "할인 상품",
-    "external_id": "sale-products-v1",
-    "source": "custom",
     "filters": [
       { "field": "discount", "operator": "greater than", "value": 0 }
     ],
     "results_limit": 20,
     "sort_field": "discount",
     "sort_order": "desc"
   }
 }
```

## 인증 코드 바꾸기

Braze REST API key를 요청마다 직접 보내는 대신, 노티플라이 `/authenticate`에서 인증 토큰을 발급받아 재사용합니다. 토큰 유효 시간은 1시간입니다.

```javascript theme={null}
async function issueNotiflyToken() {
  const response = await fetch("https://api.notifly.tech/authenticate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      accessKey: process.env.NOTIFLY_ACCESS_KEY,
      secretKey: process.env.NOTIFLY_SECRET_KEY
    })
  });

  const body = await response.json();
  if (!response.ok || typeof body.data !== "string") {
    throw new Error(`Notifly authentication failed: ${response.status}`);
  }
  return body.data;
}
```

<Note>
  운영 코드에서는 인증 토큰을 요청마다 새로 발급하지 말고 만료 전까지 메모리에 캐시하세요. Access Key, Secret Key, 토큰과 인증 응답 본문은 로그에 남기지 마세요. 자세한 내용은 [API 시작하기](/ko/api-reference/getting-started)를 참고하세요.
</Note>

## 생성 요청 변환하기

Braze의 `catalogs[0]` 객체에 `data_classification`을 추가합니다. 필드 순서는 유지하고, 첫 번째 필드가 `id:string`인지 확인합니다.

```diff theme={null}
 {
   "catalogs": [
     {
       "name": "products",
       "description": "Product catalog",
+      "data_classification": "non_personal",
       "fields": [
         { "name": "id", "type": "string" },
         { "name": "name", "type": "string" },
         { "name": "price", "type": "number" }
       ]
     }
   ]
 }
```

다음 항목은 생성 전에 정리해야 합니다.

* 필드가 500개를 넘으면 사용하지 않는 필드를 제거하거나 카탈로그를 분리합니다.
* 카탈로그·필드·아이템 ID는 최대 250자이며 영문 대소문자, 숫자, 하이픈, 밑줄만 사용합니다.
* `array` 값은 문자열 배열이며 최대 100개입니다.
* `geo` 값은 `[longitude, latitude]` 순서입니다.
* `time` 값은 ISO 8601 문자열 또는 Unix seconds입니다. 저장될 때 UTC ISO 8601 문자열로 정규화됩니다.
* `object`의 key에는 점(`.`)과 달러 기호(`$`)를 사용할 수 없습니다.
* 문자열 값은 최대 5,000자, 아이템 한 개는 최대 1 MiB입니다.

## 응답 처리 바꾸기

### 성공 응답

Braze 단건 생성은 다음처럼 성공 메시지를 반환합니다.

```json theme={null}
{ "message": "success" }
```

노티플라이는 생성하거나 조회한 리소스를 `data`에 반환합니다.

```json theme={null}
{
  "data": {
    "id": "sku-001",
    "name": "에브리데이 백팩",
    "price": 59000
  },
  "error": null
}
```

일괄·필드·셀렉션·삭제 요청이 성공하면 다음처럼 빈 성공 응답을 반환합니다.

```json theme={null}
{ "data": null, "error": null }
```

기존 코드가 `response.message === "success"` 또는 `status === 202`만 확인한다면, 노티플라이에서는 `response.error === null`과 엔드포인트별 `200`·`201`을 확인하도록 바꿔야 합니다.

### 오류 응답

Braze의 `errors[]`를 파싱하던 코드는 노티플라이의 `error.code`, `error.message`, 선택적인 `error.details`를 읽도록 바꿉니다.

```json theme={null}
{
  "data": null,
  "error": {
    "code": "INVALID_ARGUMENT",
    "message": "Invalid bulk item request.",
    "details": [
      {
        "field": "items[0].unknown_field",
        "path": ["items", 0, "unknown_field"],
        "code": "invalid-fields",
        "message": "Unrecognized field: unknown_field."
      }
    ]
  }
}
```

오류 문자열을 비교하지 말고 HTTP 상태와 `error.code`를 기준으로 처리하세요. `details`가 있으면 `field` 또는 `path`로 잘못된 입력을 표시할 수 있습니다.

### 페이지네이션

두 API 모두 한 번에 최대 50개 아이템을 반환합니다. 노티플라이는 다음 cursor를 응답 본문과 `Link` 헤더에 함께 제공합니다.

```json theme={null}
{
  "data": {
    "items": [],
    "next_cursor": "eyJhZnRlciI6InNrdS0wNTAifQ",
    "links": {
      "next": "/v1/projects/.../catalogs/products/items?cursor=..."
    }
  },
  "error": null
}
```

Braze의 `Link` header만 따라가던 코드는 그대로 header를 사용할 수 있습니다. 새 구현에서는 `data.next_cursor`가 `null`이 될 때까지 요청해도 됩니다. cursor 값은 API 내부 형식이므로 해석하거나 직접 만들지 마세요.

## 데이터 옮기기

### 1. Braze 자산 조사

먼저 다음을 기록합니다.

* 카탈로그 이름, 설명, 필드 순서와 타입
* 아이템 수와 전체 데이터 크기
* 메시지에서 사용하는 `catalog_items`, `catalog_selection_items` Liquid
* 셀렉션 이름, 필터, 정렬, 결과 개수
* 카탈로그를 쓰는 batch job, webhook, 운영 도구와 API key 권한
* 필드가 `id` 포함 500개 이하인지, JSON 중첩이 50단계 이하인지
* object key에 `.` 또는 `$`가 없는지와 개인 식별 정보가 포함되지 않았는지

Braze의 공개 API에는 셀렉션 목록을 조회하는 엔드포인트가 없습니다. 셀렉션 정의는 Braze Dashboard와 기존 설정 소스에서 별도로 확인하세요.

### 2. Braze 아이템 내보내기

`GET /catalogs/{catalog_name}/items`를 호출하고 `Link` header의 `rel="next"`가 없어질 때까지 50개씩 수집합니다. 내보낸 JSON과 아이템 수를 변경하지 않은 원본 증빙으로 보관합니다.

이관 파일은 암호화된 저장소에 두고 작업 담당자에게만 최소 권한을 부여하세요. 보관 기한을 정해 검증과 되돌리기 기간이 끝나면 삭제합니다. 개인 식별 정보를 발견하면 이관을 중단하고 파일을 격리한 뒤 해당 필드를 제거하거나 유저 속성으로 분리하세요. 파일 내용이나 인증 정보를 티켓, 채팅, 애플리케이션 로그에 붙이지 마세요.

### 3. 노티플라이 카탈로그 만들기

Braze 필드 정의에 `data_classification: "non_personal"`을 추가해 `POST /v1/projects/{projectId}/catalogs`로 생성합니다. 같은 이름으로 다시 만들면 충돌하므로 재실행 전에 기존 생성 여부를 조회하세요.

### 4. 아이템 가져오기

이관이 끝날 때까지 Braze를 기준 원본으로 유지합니다. 내보낸 아이템을 최대 50개씩 나누고, 요청 본문이 4 MiB에 가까우면 묶음 크기를 더 줄입니다.

반복 실행이 필요한 전체 복사는 일괄 `PUT /catalogs/{catalogName}/items`가 안전합니다. 같은 ID는 전체 교체하고 없는 ID는 만듭니다. 새 아이템만 허용하고 중복을 오류로 잡아야 할 때만 `POST`를 사용하세요.

각 묶음마다 카탈로그 이름, 메서드, 정렬한 아이템 ID, 아이템 수, 요청 본문 hash, 시작·완료 시각, 결과 상태를 체크포인트에 기록합니다. 한 요청의 변경은 트랜잭션으로 처리되므로 `200` 응답을 받은 뒤에만 완료로 표시합니다.

응답을 받지 못했거나 `500`을 받았다면 바로 같은 `POST`를 반복하지 마세요. 단건 `GET /items/{itemId}`로 해당 묶음의 반영 여부를 확인하고 빠진 아이템만 다시 보냅니다. `PUT`은 전체 요청 본문을 반복 적용할 수 있습니다. 재시도 간격은 지수 방식으로 늘리고 무작위 지연을 더해 다음처럼 처리합니다.

* `401`: 인증 토큰을 새로 발급한 뒤 재시도
* 네트워크 timeout·`500`: 반영 여부를 조회한 뒤 제한적으로 재시도
* `400`·`403`·`404`·`409`·`413`: 입력, 권한, 대상, 중복, 묶음 크기를 고치기 전에는 자동 재시도하지 않음

### 5. 셀렉션 복원하기

아이템 적재가 끝난 뒤 조사 단계에서 기록한 셀렉션을 `POST /catalogs/{catalogName}/selections`로 하나씩 만듭니다. Braze 요청의 `external_id`와 `source`는 제거하고, 필드 타입별 operator와 `null` 제한을 적용합니다.

성공한 셀렉션 이름과 요청 hash를 `200` 응답 뒤 체크포인트에 기록하세요. 노티플라이 공개 API에는 셀렉션 목록·수정 엔드포인트가 없습니다. timeout으로 결과를 모르면 해당 셀렉션을 사용하는 Liquid를 테스트해 먼저 존재 여부와 결과를 확인합니다. 정의가 잘못된 경우에만 영향 범위를 확인한 뒤 `DELETE`하고 다시 만드세요.

이후 아이템이나 필드를 변경하면 저장된 셀렉션 결과는 같은 요청 안에서 다시 계산됩니다.

### 6. 검증하기

* 노티플라이 `GET /catalogs`의 `num_items`가 Braze에서 내보낸 아이템 수와 같은지 확인합니다.
* 모든 페이지를 다시 조회해 ID 집합, 요청 본문 hash, 주요 필드 값을 비교합니다.
* 메시지 미리보기에서 실제 운영에 쓰는 Liquid를 실행합니다.
* 없는 ID, 빈 배열, `null`, 한글·이모지, `time`, `geo`, object, array 값을 포함한 경계 사례를 확인합니다.
* 셀렉션별 아이템 수와 정렬 결과를 비교합니다. 정렬을 생략한 셀렉션은 무작위 결과이므로 동일 순서를 기대하지 않습니다.
* 미해결 묶음과 셀렉션 체크포인트가 없는지 확인합니다.

### 7. 호출 경로 전환하기

1. 조회와 메시지 렌더링은 Braze를 계속 사용한 채, 쓰기는 Braze에 먼저 적용하고 노티플라이에 같은 변경을 보냅니다. 노티플라이 실패는 재처리 대기열에 기록합니다.
2. 사전에 정한 관찰 기간 동안 아이템 수·hash·주요 값·셀렉션 결과와 API 오류를 비교합니다.
3. 불일치와 미해결 재처리가 없을 때 조회 경로와 메시지 Liquid를 노티플라이로 전환합니다.
4. 전환 후 불일치, 지속적인 API 오류, Liquid 렌더링 오류가 생기면 조회 경로를 Braze로 되돌리고 노티플라이 전용 쓰기를 중지한 뒤 체크포인트에서 다시 동기화합니다.
5. 되돌리기 기간이 지나고 양쪽이 계속 일치하면 Braze 쓰기를 중단합니다.
6. Braze Catalog 삭제는 별도 승인과 백업 확인 후 진행합니다.

<Warning>
  마이그레이션 도중 Braze Catalog를 먼저 삭제하지 마세요. 노티플라이의 아이템 수, 주요 값, Liquid 렌더링을 검증한 뒤 별도 변경으로 정리하는 것이 안전합니다.
</Warning>

## Liquid 전환

기본 문법은 같으므로 카탈로그와 필드 이름을 유지했다면 대부분 그대로 사용할 수 있습니다.

```liquid theme={null}
{% catalog_items products {{ user["favorite_product_id"] }} %}
{% if items.size > 0 %}
  {{ items[0].name }} - {{ items[0].price }}원
{% endif %}
```

```liquid theme={null}
{% catalog_selection_items products sale-products %}
{% for item in items %}
  {{ item.name }}
{% endfor %}
```

<Warning>
  Braze에서 `:rerender` 대상 값 안에 다른 `catalog_items` 또는 `catalog_selection_items` 태그를 넣었다면 전환 전에 제거하거나 한 단계로 펼치세요. 노티플라이는 재귀적인 카탈로그 태그를 렌더링 오류로 처리합니다.
</Warning>

다만 API 응답까지 같다는 뜻은 아닙니다. Catalog REST 클라이언트의 인증·URL·상태 코드·응답 파서는 앞 절의 차이에 맞춰 별도로 수정해야 합니다.

## 공식 참고 자료

* [Braze Catalog API](https://www.braze.com/docs/api/endpoints/catalogs)
* [Braze: Create catalog](https://www.braze.com/docs/api/endpoints/catalogs/catalog_management/synchronous/post_create_catalog)
* [Braze: List multiple catalog item details](https://www.braze.com/docs/api/endpoints/catalogs/catalog_items/synchronous/get_catalog_items_details_bulk)
* [Braze: Create catalog items in bulk](https://www.braze.com/docs/api/endpoints/catalogs/catalog_items/asynchronous/post_create_catalog_items_bulk)
* [Braze: Create catalog selection](https://www.braze.com/docs/api/endpoints/catalogs/catalog_selections/asynchronous/post_create_catalog_selections)
* [Braze API rate limits](https://www.braze.com/docs/api/api_limits)
* [노티플라이 카탈로그 가이드](/ko/user-guide/catalogs/guide)
* [노티플라이 Catalog REST API 시작하기](/ko/api-reference/catalogs/getting-started)
