블로그

2026년 8월 29일 · 5분 읽기

거래가 없는 알림이 URL 등록을 실패로 만든다

서버 알림 웹훅은 이미 완비돼 있었다. 그런데 테스트 알림 하나가 400을 받아 등록이 실패로 표시될 상태였다. 두 구멍을 닫고 왕복으로 증명한 기록이다.

  • StoreKit
  • App Store
  • 웹훅
  • Go

구독 상태를 서버가 알기 위해서는 App Store가 보내는 알림을 받아야 한다. 그 URL을 콘솔에 등록하기 전에 웹훅을 먼저 훑었다. 서명 검증, 멱등, 역행 방지, 레이트리밋이 모두 있었다. 그런데 등록하면 실패로 표시될 상태였다. 이유는 알림 한 종류의 모양이었다.

웹훅은 이미 완비돼 있었다

있는 것부터 확인했다. 알림 식별자를 상태 갱신과 같은 트랜잭션에서 선점하므로 같은 알림이 두 번 와도 두 번 적용되지 않는다.

// internal/api/billing.go:210-226
func claimAppleNotification(app core.App, n *appstore.NotificationV2) (bool, error) {
	coll, err := app.FindCollectionByNameOrId("apple_notification_inbox")
	...
	record.Set("notification_uuid", n.NotificationUUID)
	...
	} else if existing, lookupErr := findFirstOptional(app, "apple_notification_inbox",
		"notification_uuid = {:n}", map[string]any{"n": n.NotificationUUID}); lookupErr == nil && existing != nil {
		return false, nil

역행 방지도 있었다. 더 이른 서명 알림이 새 상태를 덮지 못하게 shouldApplyAppleEvent가 막는다. 재시도와 순서 뒤바뀜은 이미 처리된 문제였다.

거래가 없는 알림이 등록을 깨뜨린다

App Store Connect에서 URL을 저장하면 Apple이 테스트 알림을 보낸다. 그 알림에는 구매가 없다. 따라서 signedTransactionInfo가 없다.

디코드를 시도하면 무엇이 나오는지는 같은 함수에 직접 물어보면 된다.

$ go test ./internal/api/ -run TestProbeDecodeEmptyTransaction -v   # 거래 정보가 빈 알림을 트랜잭션으로 읽으면
    zz_probe_test.go:14: DecodeTransaction("") error: malformed JWS: expected 3 segments
--- PASS: TestProbeDecodeEmptyTransaction (0.00s)
ok  	github.com/clab-one/justsend-platform/backend/internal/api	0.483s

이 오류가 그대로 400이 되어 등록 화면에 표시된다.

그 400은 우리 코드가 내린 판단이었다. 거래를 디코드하지 못했으니 잘못된 요청이라고 답한 것이다. 그런데 그 알림은 잘못된 요청이 아니라 구매가 없는 정상 알림이었다.

알림 하나가 지나는 갈림길

고친 뒤의 판정은 두 갈래다.

거래가 없는 알림도 200으로 인지한다

// internal/api/billing.go:155-166
// Apple's "Request a Test Notification" — and every other lifecycle event
// that carries no purchase — arrives without `signedTransactionInfo`.
// Decoding it as a transaction fails, and the 400 that follows is what App
// Store Connect shows next to the URL, so the endpoint would look broken
// the moment it is registered. Environment comes from the notification
// payload itself, which every V2 body carries.
if n.Data.SignedTransactionInfo == "" {
	if err := recordAppleNotificationWithoutTransaction(e.App, n); err != nil {
		return fromError(e, err)
	}
	return e.NoContent(http.StatusOK)
}

거래가 없으면 기록만 하고 200으로 인지한다. 환경은 거래가 아니라 알림 본문에서 읽는다 — V2 본문은 모두 환경을 싣기 때문이다. 이 한 갈래가 없으면 구매가 없는 모든 lifecycle 이벤트가 같은 400을 받는다.

400은 어디에 보이는가

이 결함이 위험한 이유는 눈에 보이는 자리가 우리 로그가 아니라는 것이다. 등록 화면은 서버가 무엇을 로그에 적었는지 모르고, 마지막 응답 코드만 보여 준다. 그래서 완비된 웹훅이 "실패"로 표시되고, 다음 사람은 서명 검증부터 다시 의심하게 된다.

응답 코드 하나가 진단을 엉뚱한 방향으로 보낸다는 점에서 이것은 문서 문제가 아니라 인터페이스 문제다. 200과 400의 의미를 알림 종류별로 정해 두지 않으면, 정상 알림이 우리 쪽 오류로 보고된다.

환경을 둘 다 받되 섞지 않는다

두 번째 구멍은 환경이었다. 서버가 production만 수용하면 TestFlight 샌드박스 구매는 서버에 도달하지 못한다. 그래서 둘 다 받되 저장할 때 환경을 컬럼으로 가른다.

예외가 하나 있다. Xcode 환경의 알림은 받지 않는다. 로컬 구성으로 만든 거래는 Apple이 서명하지 않으므로, 그것을 받아들이면 서명 검증을 통과하지 않은 상태가 저장된다.

// internal/api/billing_test.go:85-90
// its payloads are not Apple-signed; it must never enter the inbox.
local := &appstore.NotificationV2{NotificationType: "TEST", NotificationUUID: "test-3", SignedDate: 1_700_000_000_000}
local.Data.Environment = "Xcode"

등록은 왕복으로 증명한다

콘솔에는 "테스트 알림 보내기" 버튼이 없다. 그 경로는 서버 API 하나다. 공식 문서가 이렇게 적는다.

The endpoint prompts the App Store server to send your server a notification with the TEST notificationType. It sends it to your sandbox URL if you call this endpoint's sandbox URL. This endpoint responds with a testNotificationToken.

토큰을 받고, 그 토큰으로 상태를 다시 물어 결과를 읽는다. 우리 기록에 남은 결과는 이렇다.

sandbox: SUCCESS at 2026-08-13T04:46:35Z
notificationType: TEST

Apple이 전달 성공을 기록했다는 뜻이다. 서버 로그만 보면 "우리가 받았다"까지만 알 수 있고, Apple이 성공으로 셌는지는 알 수 없다. 등록의 성패는 Apple 쪽 기록이 정본이다.

왕복이 없으면 무엇을 모르나

서버 로그에 요청이 찍혔다는 사실은 "우리가 받았다"만 말한다. Apple이 그 전달을 성공으로 기록했는지, 재시도 큐에 남겼는지는 그 로그에 없다. 등록 상태는 Apple 쪽 기록이 정본이므로 왕복으로만 확인된다.

그래서 확인 절차를 두 단계로 고정했다. 토큰을 받는 호출과 그 토큰으로 상태를 읽는 호출이다. 한 번의 호출로 끝내려고 하면 "보냈다"와 "닿았다"를 구분할 수 없다.

지금 지키는 계약

네 가지가 테스트로 고정돼 있다. 중복 알림의 무동작, 거래 없는 알림의 기록, 낡은 알림의 무동작, 공개 입구의 예산이다.

$ go test ./internal/api/ -run "AppleNotification" -v
--- PASS: TestClaimAppleNotificationDuplicateIsNoOp (0.57s)
--- PASS: TestAppleNotificationWithoutTransactionIsRecorded (0.56s)
--- PASS: TestOlderAppleNotificationCannotRegressEntitlement (0.00s)
--- PASS: TestAppleNotificationWebhookHasAnEntryBudget (0.56s)
ok  	github.com/clab-one/justsend-platform/backend/internal/api	2.227s

세 번째 계약이 이 글에서 가장 중요한 것이다. 알림은 순서를 보장하지 않으므로, 만료 알림이 갱신 알림보다 늦게 도착하는 일이 실제로 일어난다. 서명 시각을 비교하지 않으면 그때 사용자의 구독이 꺼진다.

남은 제약은 하나다. 이 계약들은 우리 서버가 무엇을 하는지만 증명한다. Apple이 실제로 언제 무엇을 보낼지는 증명하지 않으므로, 새 알림 유형이 생기면 그 유형이 거래를 싣는지부터 다시 확인해야 한다.

다음 웹훅에서 먼저 할 일

이 사건에서 배운 순서는 이렇다. 웹훅을 만들 때는 구매를 싣는 알림부터 구현하게 되지만, 등록을 통과시키는 것은 구매가 없는 알림이다. 그래서 첫 구현 목록의 맨 앞에 두어야 하는 것은 결제 처리가 아니라 "거래가 없는 본문을 어떻게 인지하는가"다.

그리고 등록 뒤에는 반드시 왕복을 한 번 돌린다. 콘솔이 초록으로 보이는 것과 Apple이 성공으로 기록한 것은 다른 사실이고, 둘이 어긋나는 순간 사용자의 구독 상태가 조용히 낡는다.