Skip to main content
대상 독자: 키오스크/CCU/카드단말 등 장치 클라이언트 개발자, 그리고 장치를 인증·수신하는 서비스(키오스크 API, 디바이스 게이트웨이) 개발자. 기존 audit 자동 캡처 체계(Prisma extension + Mutation 자동 래핑, ADR-0007 참고)는 core-svc의 GraphQL mutation과 DB 쓰기를 기준으로 동작한다. 물리 장치가 보내는 이벤트는 이 경로를 그대로 타지 않는 경우가 많아 — 화면 진입처럼 DB 쓰기가 없는 사용자 행위, 카드단말 불량처럼 core-svc mutation이 아닌 이벤트 — 별도 계약이 필요하다. 이 계약의 구체적 구현이 subscription-svcreportDeviceEvents다(§2).

1. 이벤트 분류 2종

분류category설명예시
사용자 행위 이벤트BEHAVIOR게스트가 장치를 조작한 행위. 고볼륨kiosk.screen.entered, kiosk.checkin.started, kiosk.checkin.completed, kiosk.payment.attempted, kiosk.payment.failed
장치 상태/헬스 이벤트DEVICE_HEALTH장치 자체의 상태 변화, 장애device.card_reader.fault, device.printer.paper_out, ccu.connection.lost, ccu.door.opened
categoryseverity(INFO/WARN/ERROR/CRITICAL)는 별도 분류 필드로 둔다 — actionType=EVENT 하나로 뭉뚱그리지 않는다. 화면 진입처럼 고볼륨인 BEHAVIOR 이벤트는 보존기간과 기본 조회 노출 정책(예: 기본 조회에서 제외, 별도 조회에서만 노출)을 DEVICE_HEALTH와 분리할 수 있어야 하기 때문이다.

2. 수집 경로 & 신뢰 경계 원칙

장치는 audit-svc에 직접 쓰지 않는다. 수신 서비스는 apps/subscription-svc에 신설하는 GraphQL 배치 mutation reportDeviceEvents 다 — 같은 서비스가 이미 publishAccommodationNotification으로 업장 알림을 처리하고 있고, 장치 인증 접근 제어(isKioskSelf/isCcuSelf, apps/subscription-svc/src/graphql/helpers/accessControl.ts)도 거기 갖춰져 있으므로 새 서비스를 만들지 않고 그 위에 얹는다. 용도 구분: publishAccommodationNotification은 프런트/키오스크 화면에 실시간으로 보여줄 알림(pubsub 구독 대상)이고, reportDeviceEvents는 화면에 보여주지 않는 이벤트 로그(audit 파이프라인 적재 전용)다. 알림이 필요 없는 화면 진입·장치 헬스 이벤트를 알림 mutation에 얹지 않는다. 호출 흐름:
  1. 키오스크/CCU가 게이트웨이를 경유해 장치 인증 컨텍스트(context.extra.user.kioskId 또는 .ccuId)를 갖고 reportDeviceEvents를 호출한다.
  2. resolver가 isKioskSelf/isCcuSelf로 요청의 deviceId가 인증된 장치 본인인지 검증한다 — requestAdultAuthentication(apps/subscription-svc/src/graphql/resolver/notification.ts:223)이 쓰는 것과 동일한 패턴: isKioskSelf(context.extra, { kioskId })가 거짓이면 즉시 거부.
  3. accommodationId요청 바디 값을 그대로 믿지 않는다. requestAdultAuthenticationkioskAccommodationId !== accommodationId일 때 KIOSK_ACCOMMODATION_MISMATCH로 거부하듯, subscription-svc가 인증된 장치의 등록 정보(context.extra.user.accommodationId)에서 조회한 값과 요청값을 대조해 불일치 시 거부한다.
  4. subscription-svc가 actorType/contextId 등을 enrich한 뒤 기존 audit 파이프라인(publishAuditLogBatch)으로 발행한다.
  • accommodationId, kioskId 등 장치 신원에 관한 값은 장치가 주장하는 값을 신뢰하지 않는다. resolver가 인증 컨텍스트에서 서버 측이 직접 조회해 채운다.
  • 장치가 보낸 페이로드의 accommodationId/kioskId 필드는 참고용으로만 취급하고, 서버가 조회한 값과 다르면 요청을 거부한다.

3. 클라이언트 전송 계약

장치가 이벤트를 보낼 때 요청에 실어야 하는 필드:
필드필수설명
eventId장치가 생성하는 ULID/UUID. 오프라인 재전송 시 서버 측 멱등 키로 쓰인다
occurredAt장치 로컬 시각(ISO 8601). 서버는 수신 시각을 receivedAt으로 별도 기록하고 둘의 차이(클럭 스큐)를 모니터링한다
eventType이벤트 카탈로그에 등록된 안정적인 점(dot) 표기 이름 (예: kiosk.screen.entered)
categoryBEHAVIOR | DEVICE_HEALTH
severity✅ (기본값 INFO)INFO | WARN | ERROR | CRITICAL
deviceId장치 고유 식별자 (예: kioskId)
sessionId선택게스트 세션/체크인 플로우 식별자
resourceRefs선택알고 있는 대상 리소스 참조 (reservationId, roomId 등)
payload선택이벤트별 최소 필드만. 10KB 이내 권장, 초과 시 rejected(PAYLOAD_TOO_LARGE)로 명시 거부(절단 아님 — ADR-0007 §2.4 참고)

금지 필드 (payload에 절대 포함 금지)

카드번호/트랙데이터, 비밀번호, 인증 토큰 원문, 주민등록번호 등. 서버 측 캡처 계층도 재귀 마스킹(libs/audit/src/extension/mask.ts)을 거치지만, 클라이언트가 애초에 보내지 않는 것이 원칙이다 — 마스킹은 마지막 방어선이지 전송 허용 기준이 아니다.

배치 전송 API — reportDeviceEvents

오프라인 버퍼 플러시를 위해 단건이 아닌 배열 + 부분 성공 응답 형태를 기본으로 제공한다. subscription-svc의 GraphQL mutation이다.
type Mutation {
  reportDeviceEvents(input: ReportDeviceEventsInput!): ReportDeviceEventsResult!
}

input ReportDeviceEventsInput {
  kioskId: String   # kioskId, ccuId 중 정확히 하나만 제공 — isKioskSelf/isCcuSelf 검증 대상
  ccuId: String
  accommodationId: String!  # 서버가 인증 컨텍스트와 대조, 불일치 시 거부
  events: [DeviceEventInput!]!
}

input DeviceEventInput {
  eventId: String!
  occurredAt: DateTime!
  eventType: String!
  category: DeviceEventCategory!   # BEHAVIOR | DEVICE_HEALTH
  severity: DeviceEventSeverity! = INFO
  deviceId: String!
  sessionId: String
  resourceRefs: JSON
  payload: JSON
}

type ReportDeviceEventsResult {
  accepted: [String!]!      # eventId 목록
  duplicated: [String!]!    # 이미 처리된 eventId — 클라이언트는 accepted와 동일하게 버퍼에서 제거
  rejected: [RejectedDeviceEvent!]!
}

type RejectedDeviceEvent {
  eventId: String!
  reason: String!
}

4. 오프라인/재시도 시맨틱

  • 로컬 버퍼: 장치는 네트워크 단절 시 이벤트를 로컬에 버퍼링한다. 버퍼는 상한(건수)과 보존 시간을 둔다.
  • 재시도: 지수 백오프 + jitter로 재전송한다.
  • 전달 보장: at-least-once를 전제한다 — 서버가 eventId 기준 dedup을 책임지므로 클라이언트는 중복 전송을 두려워하지 않아도 된다.
  • 전송 우선순위: 버퍼가 가득 찼거나 네트워크가 간헐적일 때 우선순위 순서로 플러시한다.
    1. 결제/보안 관련 이벤트 (kiosk.payment.*, device.card_reader.*)
    2. 장치 헬스 이벤트
    3. 화면 이동 등 저우선순위 BEHAVIOR 이벤트
  • 버퍼 초과 시 드롭 정책: 오래된 BEHAVIOR 이벤트부터 드롭한다. DEVICE_HEALTH와 결제 관련 이벤트는 최대한 보존한다.

5. actorType 매핑 (확정)

이벤트 주체actorType
키오스크 상 게스트 행위 (체크인, 결제 등)KIOSK
장치 자체 상태 변화 (카드단말 불량, 용지 부족 등)DEVICE
AuditActorType(libs/audit/src/context/audit-context.ts)에 DEVICE를 추가한다. reportDeviceEvents는 이벤트별 category를 보고 enrich 단계에서 BEHAVIORKIOSK, DEVICE_HEALTHDEVICE로 매핑해 채운다.

6. 서버측 수신 서비스 체크리스트 (subscription-svc · reportDeviceEvents)

  1. 장치 신원 검증 — resolver가 인증 이전에 kioskId/ccuId정확히 1개만 제공됐는지 확인한다. 둘 다 없거나 둘 다 있으면 INVALID_DEVICE_IDENTITY로 즉시 거부.
  2. 장치 인증isKioskSelf/isCcuSelf(accessControl.ts)로 요청의 kioskId/ccuId가 인증 컨텍스트(context.extra.user)의 장치 본인인지 검증. 불일치 시 KIOSK_AUTH_REQUIRED 등으로 즉시 거부.
  3. accommodationId 대조 — 인증된 장치의 등록 accommodationId와 요청의 accommodationId를 대조, 불일치 시 KIOSK_ACCOMMODATION_MISMATCH로 거부(requestAdultAuthentication과 동일 패턴).
  4. 스키마 검증eventId/occurredAt/eventType/category 필수 필드와 eventType 카탈로그 존재 여부를 확인.
  5. eventId 멱등 — 이미 처리된 eventIdaccepted가 아니라 duplicated로 응답하고 재처리하지 않는다.
  6. EnrichaccommodationId(§2)/actorType(§5)/contextId를 서버가 직접 채운다. 장치가 보낸 값은 무시하거나 대조용으로만 쓴다.
  7. Audit 파이프라인 발행publishAuditLogBatch(@vpms-cluster/audit)를 호출한다. ADR-0007의 outbox 전환은 이 호출의 내부 구현만 바뀌므로 reportDeviceEvents 쪽 코드 변경은 없다.

7. 이벤트 예시

예시 1 — 화면 진입 배치 (BEHAVIOR)

reportDeviceEvents mutation 호출 변수:
{
  "input": {
    "kioskId": "kiosk-gangnam-01",
    "accommodationId": "acc-01J7X1...",
    "events": [
      {
        "eventId": "01J8X9K3QZ7R2M4N5P6Q7R8S9T",
        "occurredAt": "2026-07-10T09:15:32.104+09:00",
        "eventType": "kiosk.screen.entered",
        "category": "BEHAVIOR",
        "deviceId": "kiosk-gangnam-01",
        "sessionId": "sess-01J8X9K3QZ",
        "payload": { "screenName": "checkin-guest-info" }
      },
      {
        "eventId": "01J8X9K3R18F3N5P6Q7R8S9T0U",
        "occurredAt": "2026-07-10T09:16:01.552+09:00",
        "eventType": "kiosk.checkin.started",
        "category": "BEHAVIOR",
        "deviceId": "kiosk-gangnam-01",
        "sessionId": "sess-01J8X9K3QZ",
        "resourceRefs": { "reservationId": "01J7Y2..." },
        "payload": {}
      }
    ]
  }
}

예시 2 — 카드단말 불량 (DEVICE_HEALTH)

{
  "input": {
    "kioskId": "kiosk-gangnam-01",
    "accommodationId": "acc-01J7X1...",
    "events": [
      {
        "eventId": "01J8X9M5V2W4X6Y8Z0A1B2C3D4",
        "occurredAt": "2026-07-10T09:20:11.884+09:00",
        "eventType": "device.card_reader.fault",
        "category": "DEVICE_HEALTH",
        "severity": "ERROR",
        "deviceId": "kiosk-gangnam-01",
        "payload": {
          "faultCode": "READER_TIMEOUT",
          "reader": "MSR-206"
        }
      }
    ]
  }
}
주의: 두 예시 모두 카드번호/트랙데이터를 포함하지 않는다. 카드단말 불량 페이로드는 장애 코드와 리더 식별자만 담고, 실제 카드 데이터는 절대 포함하지 않는다(§3 금지 필드 참고).