인앱결제 한 번에 유료 기능이 무한으로 풀렸다 — ‘소비 실패’를 뭉뚱그린 대가

결제 기능은 만들 때 제일 조마조마해요. 돈이 오가니까요. 제가 만드는 안드로이드 앱에 유료 프리미엄 기능을 인앱결제(앱 안에서 구글 결제로 유료 기능을 파는 것)로 붙였는데, 실기기 테스트에서 등골이 서늘한 걸 봤습니다. 결제 한 번으로 프리미엄이 결제 없이 무한 생성되고 있었어요. 흔히 말하는 ‘money leak'(돈이 새는 버그)이죠.

이 앱의 프리미엄은 “결제 1건당 프리미엄 1회”인 1회권이에요. 그러니 한 번 결제로 프리미엄이 두 번, 세 번, 무한정 나온다는 건 완전한 사고입니다. 원인이 꽤 의외였고, 결제 로직을 짜는 분이라면 언젠가 꼭 만나는 함정이라 정리합니다. 초보인 제가 “실패”라는 걸 너무 뭉뚱그려서 생긴 일이었어요.

무슨 일이 벌어졌나 — 실기기 로그

이런 버그는 에뮬레이터에선 잘 안 드러나요. 실기기에서 진짜로 결제해보고, 그 뒤에 프리미엄을 다시 눌러보다 이상함을 느꼈습니다. logcat(안드로이드 앱이 돌면서 남기는 실행 로그)을 열었더니 이 두 줄이 떠 있었어요.

E BillingRepository: Consume failed, attempting acknowledge instead: Code=8   ← ITEM_NOT_OWNED
E NamingViewModel: Failed to consume premium purchase after successful generation

Code=8은 구글 결제가 돌려주는 ITEM_NOT_OWNED, 즉 “이 영수증은 네가 가진 게 아니야”라는 응답이에요. 결제는 됐는데, 뒤처리에서 구글이 “그거 이미 없는 거야”라고 답하고 있던 겁니다. 그런데 앱은 그 뒤로도 프리미엄을 계속 공짜로 내주고 있었어요.

잠깐, 결제 ‘뒤처리’가 뭔가 — 소비와 승인

여기서 짚고 갈 게 있어요. 결제가 성공하면 끝이 아닙니다. 구글 인앱결제(Play Billing)는 구매 후 반드시 둘 중 하나를 해야 해요.

  • 소비(consume): 소모성 상품(1회권처럼 여러 번 살 수 있는 것)에 쓴다. “다 썼다”고 알려야 사용자가 다시 살 수 있다.
  • 승인(acknowledge): 비소모성·구독 상품에 쓴다. 결제 후 3일 안에 승인하지 않으면 구글이 자동 환불해 버린다.

제 프리미엄은 1회권이라 소비(consume)를 씁니다. 위 로그의 consume failed, attempting acknowledge instead는, 소비가 실패하니까 앱이 “그럼 승인이라도 해두자”고 폴백을 시도한 흔적이에요. 그런데 그 승인마저 실패하면서 문제가 시작됐습니다.

왜 ‘무한 무료’가 됐나 — 실패를 한 덩어리로 봤다

제 앱은 프리미엄 생성 여부를 플래그 하나로 판단했어요. hasUnconsumedPurchase(아직 안 쓴 결제가 남아 있나)가 true면 프리미엄을 열어줍니다. 문제는 이 플래그가 오직 ‘소비 성공’ 때만 false로 내려가게 돼 있었다는 거예요.

정상 흐름은 이래요 — 결제 → 프리미엄 생성 → 소비 처리 → 플래그 false → 게이트 닫힘. 그런데 소비가 아까 그 ITEM_NOT_OWNED로 실패하면? 그 영수증은 이미 소비됐거나 무효한 유령 영수증인데, 코드는 “소비 실패 = 아직 안 쓴 결제”라고 뭉뚱그려서 플래그를 그대로 뒀습니다. 그래서 플래그가 영원히 true → 게이트가 영영 열린 채 → 결제 없이 무한 생성.

핵심은 이거였어요. 저는 “소비 실패”를 한 덩어리로 봤는데, 실패에도 두 종류가 있었습니다.

  • 일시적 오류(잠깐 네트워크 문제 등) — 진짜로 아직 안 쓴 결제니까 보존해야 한다.
  • ITEM_NOT_OWNED 유령 — 이미 없는 영수증이니 정리해야 한다.

이 둘을 안 갈랐던 게 화근이었어요. 채점으로 치면, 같은 ‘오답’이라도 계산 실수와 문제 조건을 잘못 읽은 건 대처가 달라야 하는 것과 같죠. (오늘의 수학쌤 비유는 여기까지만.)

어떻게 고쳤나

고친 방향은 “실패의 종류를 갈라서, 유령이면 게이트를 즉시 닫는다”였어요.

  • ITEM_NOT_OWNED(그리고 정상 OK)면 → 자격을 즉시 정리해 게이트를 닫습니다. 미소유 영수증은 승인(acknowledge)도 시도하지 않아요 — 어차피 없는 걸 승인할 순 없으니까요.
  • 반면 일시적 오류는 그대로 보존하고 재시도합니다.

여기서 자격이라는 말은 엔타이틀먼트(entitlement)예요.

엔타이틀먼트(entitlement): “이 사용자가 유료 자격을 갖고 있나”의 보유 상태. 이걸 정리한다 = 유료 게이트를 닫는다.

왜 일시적 오류는 ‘그대로 둬야’ 하나

여기서 반대쪽 함정도 있어요. “그럼 실패하면 다 정리해서 게이트 닫으면 되잖아?” 싶겠지만, 그러면 안 됩니다. 진짜로 잠깐의 네트워크 문제로 소비가 실패한 거라면, 그 사용자는 돈을 냈는데 프리미엄을 못 받는 상황이 돼요. 그 결제를 성급히 정리해 버리면, 돈 낸 사람이 손해를 보고 곧장 환불 요청으로 이어집니다.

그래서 규칙은 비대칭이어야 했어요. 유령(ITEM_NOT_OWNED)은 정리하고, 일시적 오류는 보존해서 다음에 다시 소비를 시도한다. 한쪽으로 몰면 무한 무료(money leak)가 나고, 반대로 몰면 환불 폭탄이 납니다. 돈이 걸린 로직은 이렇게 양쪽 실패 모드를 다 봐야 하더라고요.

결제 로직을 어떻게 테스트했나

결제 코드는 자동 테스트가 유난히 어려워요. 진짜 결제창이 떠야 하고, 구글 서버가 응답을 줘야 실행되니까요. 그래서 저는 “정리할까 말까”의 판단만 떼어내 순수 함수로 만들었어요.

순수 함수: 입력이 같으면 항상 같은 결과를 주고, 바깥 상태를 안 건드리는 함수. 결제 SDK 같은 복잡한 걸 흉내(목킹) 내지 않고도, 입력(응답 코드)만 넣어 단위테스트로 검증할 수 있습니다.

// 응답 코드만 보고 "게이트를 닫을지"를 답하는 순수 함수
internal fun shouldClearEntitlementOnConsumeResult(responseCode: Int): Boolean =
    responseCode == BillingClient.BillingResponseCode.ITEM_NOT_OWNED

이렇게 빼두니, 결제창을 실제로 띄우지 않고도 회귀 테스트 3개(정상 소비 → 정리 / 유령 → 정리 / 일시 오류 → 보존)로 “그 구멍이 다시 열리는지”를 잡을 수 있게 됐어요. 다음에 누가 이 부분을 건드려도, 테스트가 빨갛게 뜨면서 막아줍니다.

money leak 주의: 결제 뒤처리에서 “소비/승인 실패”를 한 덩어리로 처리하면, 유령 영수증이 유료 게이트를 영원히 열어둘 수 있습니다. 실패 코드(ITEM_NOT_OWNED 등)를 갈라서, 유령이면 자격을 즉시 정리하고 일시 오류는 보존하세요.

그래서 뭘 하면 되나

인앱결제 뒤처리를 짤 때, 이 표대로 실패를 갈라 보세요.

신호판단액션
logcat에 소비 실패 Code=8(ITEM_NOT_OWNED)🔴 유령 영수증자격 즉시 정리(게이트 닫기), 승인 시도 안 함
잠깐의 네트워크 등 일시적 오류🟡 진짜 미소비보존 → 재시도(돈 낸 사람 보호)
돈이 걸린 분기 로직🟢 회귀 위험순수 함수로 빼고 단위테스트로 못 박기

한 문장 요약: 결제에서 ‘실패’는 한 덩어리가 아니다 — 왜 실패했는지(응답 코드)를 갈라, 유령이면 자격을 정리하고 일시 오류면 보존하라. 그리고 돈이 걸린 판단은 순수 함수 + 회귀 테스트로 박아둬라.

결제는 되는 것보다 안 되는 경우를 어떻게 처리하느냐가 훨씬 중요하더라고요. 잘 될 땐 다 잘 되는데, 새는 건 늘 실패 경로에서 샙니다. 유령이면 닫고, 일시 오류면 보존하고 — 이 비대칭 하나만 지켜도 무한 무료와 환불 폭탄 사이에서 안전합니다. 저처럼 “실패면 그냥 냅두자”로 뭉뚱그렸다가 공짜 프리미엄을 무한으로 내주지 마시길요.


댓글 남기기