Skip to content
블로그로 돌아가기
튜토리얼

JSON Diff에서 타임스탬프와 ID를 무시하는 방법 (jq 없이)

JSON Diff 온라인 도구로 API 회귀 테스트의 노이즈를 제거하세요. Extended JSON Pointer 패턴으로 타임스탬프·UUID를 무시하고 실제 변경만 확인.

12분 소요

JSON Diff에서 타임스탬프와 ID를 무시하는 방법 (jq 없이)

REST 엔드포인트에 새 필드를 추가했습니다. 스냅샷 테스트가 실패합니다 — 필드가 변경되어서가 아니라 createdAt, requestId, traceId가 기록된 스냅샷과 재실행 사이에 변경되었기 때문입니다. 10분을 소비해 Diff가 노이즈임을 확인하고, 테스트를 무음 처리한 뒤 넘어갑니다. 이 일이 모든 팀원에게, 모든 빌드에서, 매주 반복됩니다.

문제는 테스트 프레임워크가 아닙니다. 문제는 원시 JSON Diff가 모든 필드를 동등하게 처리한다는 것입니다. 회전하는 UUID는 의미론적 정보를 담고 있는 필드와 구조적으로 동일합니다. “이 경로는 무시”라고 말할 방법이 없으면, Diff 표면에는 서버가 삽입한 모든 타임스탬프, 자동 증가 ID, 요청별 상관 토큰이 포함됩니다 — 이 중 어느 것도 API 계약이 변경되었는지를 반영하지 않습니다.

이 가이드는 노이즈를 원천에서 제거할 수 있는 도구와 패턴을 다룹니다: JSON Pointer 경로 문법, 배열을 위한 와일드카드 패턴, 키 기반 매칭을 위한 TypeScript 헬퍼, Vitest 스냅샷 통합, 그리고 실제로 의미 있는 내용이 포함된 경우에만 non-zero로 종료하는 소형 CI 스크립트.

”노이즈 Diff” 문제 — CI가 무관한 변경에 실패하는 이유

전형적인 API 회귀 테스트 워크플로우를 생각해봅시다. GET /orders/42의 응답을 기록합니다:

{
  "id": "ord_9f8e7d6c",
  "status": "shipped",
  "createdAt": "2026-04-01T10:00:00Z",
  "updatedAt": "2026-04-01T12:34:56Z",
  "requestId": "req_abc123",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}

해당 응답을 스냅샷으로 저장합니다. 2주 후, 동료가 shippingCarrier 필드를 추가하고 스위트를 실행합니다. Diff에는 6줄의 변경이 표시됩니다 — 4개는 서버 측에서 교체된 타임스탬프와 ID입니다. 의미 있는 변경(새 필드)은 노이즈에 묻혀 있습니다. 동료는 아무것도 깨지지 않았음을 확인하고, 스냅샷을 업데이트하며, 아무도 실제 기능 Diff를 읽지 않은 채 리뷰가 진행됩니다.

이것을 수백 개의 엔드포인트를 가진 마이크로서비스 메시 전체에 곱해봅시다. 모든 빌드에서, CI는 requestId, traceId, version, etag, x-request-start, serverTime을 포함하는 스냅샷 파일을 비교합니다. 리뷰어들은 스냅샷 Diff에 무감각해지고 읽지 않고 승인합니다. 회귀 신호가 붕괴됩니다.

해결책은 스냅샷 테스트를 중단하는 것이 아닙니다. Diff가 실행되기 전에 노이즈를 제거하는 것입니다.

노이즈의 정의: 타임스탬프, UUID, 자동 ID, 해시

변경되는 모든 필드가 노이즈는 아닙니다. processing에서 shipped로 변하는 status는 신호입니다. 문제는 설계상 교체되기 때문에 구조적으로 회귀 정보를 담을 수 없는 필드가 어느 것인지입니다.

카테고리예시 필드명변경 이유
타임스탬프createdAt, updatedAt, deletedAt, lastSeen, expiresAt서버 시계, 요청 시각
요청 상관 정보requestId, traceId, spanId, correlationId요청별 생성
자동 생성 IDid, uuid, nonce, idempotencyKey모든 삽입 시 UUID v4 / ULID / KSUID
ETag / 캐시 토큰etag, eTag, cacheKey, version (자동 증가 시)컨텐츠 해시 또는 시퀀스
빌드 메타데이터buildHash, deployId, serverVersion, hostname배포 시 설정
페이지네이션 커서nextCursor, prevCursor, pageToken인코딩된 상태, 불투명

이 표의 필드들은 무시 목록의 후보입니다. 실제 도메인 상태를 인코딩하는 필드 — status, amount, userId, items — 는 절대 목록에 포함시키면 안 됩니다.

목표는 코드에 존재하고, PR에서 리뷰되며, 보안 요구사항이 변경될 때 감사되는 무시 목록입니다. 누군가 로컬에서 작성하고 커밋하는 것을 잊어버린 일회성 jq 파이프라인이 아닙니다.

JSON Pointer (RFC 6901) 문법 입문

RFC 6901에 정의된 JSON Pointer는 JSON Patch (RFC 6902)와 대부분의 구조화된 Diff 도구에서 사용하는 주소 체계입니다. 파일시스템 경로처럼 생겼으며 JSON 문서의 특정 위치를 지정합니다.

/               → 루트 문서
/foo            → 객체 키 "foo"
/foo/bar        → "foo" 내의 중첩 키 "bar"
/items/0        → "items" 배열의 첫 번째 요소
/items/0/name   → 첫 번째 요소 내의 키 "name"

두 가지 이스케이프 시퀀스가 특수 문자를 처리합니다:

  • ~0은 리터럴 ~를 나타냅니다
  • ~1은 리터럴 /를 나타냅니다

따라서 키 a/b/a~1b로 지정되고, 키 a~b/a~0b로 지정됩니다.

JSON Pointer는 JSONPath(jq, OpenAPI에서 사용)와 핵심적인 차이가 있습니다: 정확히 하나의 위치를 지정합니다 — 와일드카드, 재귀 하강, 필터 표현식이 없습니다. RFC 6901은 엄격하고 단순합니다. 트레이드오프는, 모든 배열 요소에 걸쳐 필드를 무시하려면 와일드카드 문법으로 스펙을 확장하는 도구 또는 포인터 경로를 프로그래밍 방식으로 반복하는 방법이 필요하다는 것입니다.

JSON Patch 작업에서 path 필드를 볼 때([{"op":"replace","path":"/status","value":"shipped"}]), 이것들이 RFC 6901 포인터입니다. fast-json-patchrfc6902 같은 도구들은 이 표기법을 기본적으로 사용합니다.

와일드카드 패턴: 배열을 위한 /users/*/lastSeen

RFC 6901은 와일드카드를 정의하지 않습니다. 그러나 실용적인 대부분의 Diff 도구들은 단일 세그먼트 와일드카드로 스펙을 확장합니다: */를 가로지르지 않고 임의의 하나의 경로 세그먼트와 매칭됩니다.

Go Tools JSON Diff 도구에서 구현된 Extended JSON Pointer Pattern 규칙:

  • *는 임의의 단일 경로 세그먼트(배열 인덱스 또는 객체 키)와 매칭됩니다
  • */를 가로지르지 않습니다 — 재귀 글로브가 아닙니다
  • 키 이름에서 리터럴 별표를 매칭하려면 \*로 이스케이프합니다
  • **(이중 별표 재귀 하강)은 지원되지 않습니다 — 명시적 경로를 사용하세요

예시:

/users/*/lastSeen        → /users/0/lastSeen, /users/1/lastSeen 등과 매칭
/orders/*/updatedAt      → orders 배열의 모든 요소에서 updatedAt과 매칭
/*/requestId             → 모든 최상위 객체 키에서 requestId와 매칭
/data/items/*/id         → 중첩된 items 배열의 모든 요소에서 id와 매칭

단일 레벨 제약이 중요합니다. /users/*/profile/*/tag는 유효하며 정확히 두 레벨의 와일드카드 하강과 매칭됩니다. /users/0/tag(너무 얕음) 또는 /users/0/profile/0/meta/tag(너무 깊음)와는 매칭되지 않습니다.

전형적인 REST API 응답을 위한 실용적인 무시 목록:

/requestId
/traceId
/createdAt
/updatedAt
/data/*/createdAt
/data/*/updatedAt
/data/*/id
/pagination/nextCursor

이것은 의미론적 필드를 건드리지 않고 일반적인 경우를 처리합니다.

라이브 데모: Stripe 웹훅 페이로드에서 createdAt 무시하기

Stripe 웹훅은 구체적인 예시입니다. 모든 이벤트 봉투에는 created(Unix 타임스탬프), id(이벤트 ID), request.id, request.idempotency_key가 포함됩니다 — 모두 호출마다 교체되며 회귀 테스트에 쓸모없습니다.

원시 웹훅 페이로드:

{
  "id": "evt_1OqLmn2eZvKYlo2C9Pt1a4X7",
  "object": "event",
  "created": 1714819200,
  "type": "payment_intent.succeeded",
  "request": {
    "id": "req_Bc7dEfGhIj",
    "idempotency_key": null
  },
  "data": {
    "object": {
      "id": "pi_3OqLmn2eZvKYlo2C0Pt1a4X8",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded"
    }
  }
}

이 구조를 위한 무시 목록:

/id
/created
/request/id
/request/idempotency_key
/data/object/id

이러한 무시를 적용한 후, Diff 표면에는 type, data/object/amount, data/object/currency, data/object/status만 포함됩니다 — 결제 동작이 변경되었는지 실제로 인코딩하는 필드들입니다.

이전 및 이후 페이로드를 모두 JSON Diff 도구에 붙여넣고, 무시 경로를 입력하면 JSON Patch 출력에 의미 있는 작업만 남습니다. jq 파이프라인도, 전처리 스크립트도 필요 없습니다.

알 수 없는 키를 위한 정규식 기반 필드 매칭

JSON Pointer 경로는 미리 구조를 알 때 작동합니다. 두 가지 시나리오에서는 실패합니다: 동적 객체 키(UUID 키를 가진 맵, 사용자별 데이터 블롭)와 “Id로 끝나는 모든 필드” 패턴.

이를 위해 정규식 기반 키 매칭이 필요합니다. 다음은 JSON 트리를 순회하고 키 이름이 패턴과 일치하는 모든 포인터 경로를 수집하는 TypeScript 헬퍼입니다:

type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };

function findPathsByKeyPattern(
  value: JsonValue,
  pattern: RegExp,
  currentPath = ""
): string[] {
  const paths: string[] = [];

  if (Array.isArray(value)) {
    value.forEach((item, i) => {
      paths.push(...findPathsByKeyPattern(item, pattern, `${currentPath}/${i}`));
    });
  } else if (value !== null && typeof value === "object") {
    for (const [key, child] of Object.entries(value)) {
      const encodedKey = key.replace(/~/g, "~0").replace(/\//g, "~1");
      const childPath = `${currentPath}/${encodedKey}`;
      if (pattern.test(key)) {
        paths.push(childPath);
      }
      paths.push(...findPathsByKeyPattern(child, pattern, childPath));
    }
  }

  return paths;
}

// 사용법: 키 이름이 "Id" 또는 "At"으로 끝나는 모든 경로 수집
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]

이렇게 하면 수동으로 유지되는 문자열 집합 대신 실제 응답 형태에서 파생된 동적 무시 목록이 생성됩니다. 주의사항: 합법적인 필드가 Id로 끝나는 경우(예: userId 또는 orderId), 패턴을 세밀하게 조정하거나 결과에서 해당 경로를 빼야 합니다. 이름 패턴은 명시적 경로 목록의 대체가 아닙니다 — 모든 경로를 수동으로 관리하는 것이 비실용적인 대규모 API를 위한 보완입니다.

무시된 경로가 제거된 JSON Patch 출력

JSON Patch (RFC 6902)로 표현된 JSON Diff는 작업 목록입니다:

[
  { "op": "replace", "path": "/updatedAt", "value": "2026-05-04T09:00:00Z" },
  { "op": "replace", "path": "/status", "value": "shipped" },
  { "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]

무시된 경로를 필터링한다는 것은 path가 무시 목록의 항목(와일드카드 포함)과 일치하는 작업을 제거한다는 의미입니다. 필터링된 출력:

[
  { "op": "replace", "path": "/status", "value": "shipped" },
  { "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]

두 가지 중요한 주의사항:

  1. 비가역 경고. 필터링된 패치는 대상 문서를 재구성하는 데 적용할 수 없습니다. 소스에 비노이즈 작업만 적용하면 타임스탬프 업데이트가 누락된 문서가 생성됩니다. 문서 재구성을 위해 전체 패치를 저장하고, Diff 및 알림에는 필터링된 패치만 사용하세요.

  2. 순서 민감성. JSON Patch 작업은 순서가 있습니다. 이전 위치를 참조하는 이동 또는 복사 작업이 있는 패치 중간에서 작업을 필터링하면 유효하지 않은 패치가 생성될 수 있습니다. 필터링된 패치를 적용해야 한다면, 먼저 전체 패치를 적용한 다음 결과를 예상 상태와 다시 Diff하세요.

JSON Diff 도구는 전체 패치와 필터링된 뷰를 모두 출력하므로, 어떤 작업을 제거할지 결정하기 전에 원시 Diff를 검사할 수 있습니다.

스냅샷 테스트 레시피 (Jest / Vitest)

Vitest의 스냅샷 시스템은 값을 문자열로 직렬화하고 재실행 시 Diff합니다. 기본 직렬화기는 노이즈 필드를 포함한 모든 필드를 캡처합니다. 해결책은 스냅샷 저장 전에 무시된 경로를 제거하는 커스텀 직렬화기입니다.

// tests/setup/json-diff-serializer.ts
import { expect } from "vitest";

const IGNORE_PATHS = new Set([
  "/requestId",
  "/traceId",
  "/createdAt",
  "/updatedAt",
  "/data/id",
]);

function stripPaths(obj: unknown, path = ""): unknown {
  if (Array.isArray(obj)) {
    return obj.map((item, i) => stripPaths(item, `${path}/${i}`));
  }
  if (obj !== null && typeof obj === "object") {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      const childPath = `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`;
      if (!IGNORE_PATHS.has(childPath)) {
        result[key] = stripPaths(value, childPath);
      }
    }
    return result;
  }
  return obj;
}

expect.addSnapshotSerializer({
  test: (val) => val !== null && typeof val === "object" && !Array.isArray(val),
  print: (val, serialize) => serialize(stripPaths(val)),
});

vitest.config.ts에 등록합니다:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    setupFiles: ["./tests/setup/json-diff-serializer.ts"],
  },
});

이제 스냅샷은 다음과 같습니다:

it("returns order with shipping carrier", async () => {
  const result = await getOrder("ord_42");
  expect(result).toMatchSnapshot(); // createdAt, requestId 등이 제거됨
});

스냅샷 파일에는 안정적인 필드만 포함됩니다. 타임스탬프 변경 후 스위트를 다시 실행하면 Diff가 0이 됩니다. shippingCarrier 필드가 추가되면, 스냅샷 Diff에 정확히 그 하나의 변경만 표시됩니다. JSON Diff 도구를 사용해 스냅샷을 업데이트하기 전에 두 기록된 응답 사이에 무엇이 변경되었는지 수동으로 확인할 수도 있습니다.

CI 통합: 의미 있는 Diff에서만 빌드 실패

최종 목표: 참조 응답을 다운로드하고, 라이브 엔드포인트를 호출하고, 둘을 Diff하고, 노이즈를 제거하고, 의미 있는 작업이 남아 있는 경우에만 non-zero로 종료하는 CI 단계.

// scripts/api-regression-check.ts
import { applyPatch, compare } from "fast-json-patch";
import { readFileSync } from "fs";

const IGNORE_PATTERNS = [
  /^\/requestId$/,
  /^\/traceId$/,
  /^\/createdAt$/,
  /^\/updatedAt$/,
  /^\/data\/\d+\/createdAt$/,
  /^\/data\/\d+\/updatedAt$/,
  /^\/data\/\d+\/id$/,
];

function isMeaningfulOp(op: { path: string }): boolean {
  return !IGNORE_PATTERNS.some((re) => re.test(op.path));
}

async function main() {
  const reference = JSON.parse(readFileSync("reference.json", "utf-8"));
  const response = await fetch(process.env.API_URL + "/orders").then((r) => r.json());

  const ops = compare(reference, response);
  const meaningfulOps = ops.filter(isMeaningfulOp);

  if (meaningfulOps.length > 0) {
    console.error("Meaningful diff detected:");
    console.error(JSON.stringify(meaningfulOps, null, 2));
    process.exit(1);
  }

  console.log(`Clean diff (${ops.length} noise ops suppressed).`);
}

main().catch((e) => { console.error(e); process.exit(1); });

GitHub Actions 단계:

- name: API regression check
  env:
    API_URL: ${{ secrets.STAGING_API_URL }}
  run: npx tsx scripts/api-regression-check.ts

fast-json-patchcompare 함수는 RFC 6902 작업을 반환합니다. 필터는 Vitest 직렬화기와 동일한 정규식 접근 방식을 사용해 path 문자열에 대해 실행됩니다. 스크립트는 노이즈만 변경된 경우 0으로, 의미 있는 필드가 다른 경우 1로 종료됩니다.

와일드카드 매칭(/data/\d+\/updatedAt 대신 /data/*/updatedAt 처리)을 위해, 정규식 목록을 글로브-투-정규식 변환기로 교체하거나, JSON Diff 도구에서 허용하는 무시 목록 형식을 사용해 내보내기를 통해 파이프라인에 통합하세요.

무시하지 말아야 할 때: 보안 민감 필드, 감사 추적

무시 목록은 강력합니다. 규율 없이 적용하면 위험하기도 합니다. 일부 필드는 노이즈처럼 보이지만 보안상 중요한 정보를 담고 있습니다.

무시하지 마세요:

  • 감사 로그 또는 금융 기록createdAt. 트랜잭션 타임스탬프가 잘못되면 버그입니다 — 노이즈가 아닙니다. 감사 테이블은 정확히 타임스탬프 이상을 통한 변조를 감지하기 위해 존재합니다.
  • 결제 인텐트, 인보이스 또는 규정 기록id 필드. 결제 컨텍스트에서 회전하는 ID는 중복 청구 또는 잘못 라우팅된 이벤트를 나타낼 수 있습니다.
  • 낙관적 동시성 시스템version 또는 etag. 두 프로세스가 동일한 버전으로 모두 쓰면 하나는 실패합니다. 버전 필드는 장식이 아닙니다.
  • 분산 시스템 장애 디버깅 시 traceId 또는 spanId. 이것들은 스냅샷 테스트에서는 노이즈지만 프로덕션 인시던트 분석에서는 신호입니다.
  • 보안 팀이 모니터링을 위해 표시한 모든 필드 — SIEM 규칙, 컴플라이언스 요구사항, 데이터 보존 정책이 이러한 필드의 Diff 가시성에 의존할 수 있습니다.

올바른 모델은 무시 목록을 코드 리뷰된 허용 목록으로 처리하는 것입니다: 레포지토리에 체크인되고, 각 경로가 목록에 있는 이유를 설명하는 주석이 달린 명명된 상수 또는 설정 파일. 무시 목록의 변경은 프로덕션 설정 변경과 동일한 리뷰를 요구해야 합니다.

// 명시적이고, 감사 가능하며, 변경하려면 PR 리뷰 필요
export const SNAPSHOT_IGNORE_PATHS = [
  "/requestId",   // 요청별 UUID, 의미론적 내용 없음
  "/traceId",     // OpenTelemetry 트레이스 ID, 요청별 회전
  "/createdAt",   // 서버 시계, 프로덕션 픽스처에서 안정적
  // "/amount"    // 절대 무시 금지 — 금융 필드
] as const;

필드를 무시 목록에 포함해야 할지 확신이 없다면, Diff에 포함시키는 쪽으로 오류를 범하세요. 거짓 양성(Diff의 노이즈)은 몇 초의 리뷰 비용이 듭니다. 거짓 음성(경로가 무시되어 놓친 회귀)은 사용자에게 비용을 치르게 합니다.

요약

원시 JSON Diff는 서버 응답이 설계상 교체되는 필드를 포함하기 때문에 노이즈가 많습니다. JSON Pointer (RFC 6901) 문법은 정밀한 주소 체계를 제공합니다. 와일드카드 패턴(/data/*/createdAt)은 커스텀 순회 코드 없이 배열로 확장합니다. 정규식 기반 키 매칭은 경로를 미리 알 수 없는 동적 구조를 처리합니다. 무시된 경로가 제거된 JSON Patch (RFC 6902) 출력은 CI를 위한 깔끔한 신호를 생성합니다. Vitest 커스텀 직렬화기는 스냅샷 테스트에 동일한 논리를 적용합니다. 그리고 규율 있는, 코드 리뷰된 무시 목록은 노이즈 억제가 실제 회귀를 실수로 삼키지 않도록 합니다.

JSON Diff 도구에서 직접 실습해보세요 — JSON 응답 두 개를 붙여넣고, 무시 경로를 입력하면 의미 있는 작업만 확인할 수 있습니다. JSON Formatter를 사용해 Diff 전에 압축된 응답을 정리하세요.