본문 바로가기
  • Adillete
[트러블슈팅]

[오늘의 옷장]nl.martijndwars.webpush의 410 Gone 응답 처리

by 아딜렛 2026. 5. 22.

nl.martijndwars.webpush의 410 Gone 응답 처리

문제: WebPush 구독이 만료되었을 때 자동으로 삭제하려 했으나 만료된 구독이 DB에 계속 남아있는 문제 발생

기대 동작:

  • Push 전송 시 410 Gone 응답을 받으면 해당 구독 자동 삭제
  • 만료된 구독에 불필요한 요청 발생 방지

실제 동작:

  • 410 Gone 응답을 받아도 구독이 삭제되지 않음
  • 만료된 구독에 계속 Push 전송 시도

원인 — 실제 동작 확인

nl.martijndwars.webpush 라이브러리의 실제 동작을 테스트로 확인했다.

@Test public void test410Response() { try { nl.martijndwars.webpush.Notification notification = new nl.martijndwars.webpush.Notification( "https://fcm.googleapis.com/fcm/send/INVALID_TOKEN", "BDxcO0vK8Hjrl...", "OXde8QZMgpvnN+05Dljhgw==", "test message".getBytes() ); HttpResponse response = pushService.send(notification); int statusCode = response.getStatusLine().getStatusCode(); System.out.println("Status Code: " + statusCode); // 410 System.out.println("예외 발생하지 않음"); } catch (IOException e) { System.out.println("IOException 발생: " + e.getMessage()); } }

테스트 결과

======================================== === 전송 완료 (예외 안 던짐) === Status Code: 410 Reason: Gone ======================================== 결론: 410 Gone 응답이 왔지만 예외를 던지지 않음 HttpResponse 객체로 반환됨

원인 분석: nl.martijndwars.webpush는 4xx/5xx HTTP 응답을 예외로 던지지 않는다.

  • Apache HttpClient 기반으로 동작
  • OkHttp와 유사한 방식: 모든 HTTP 응답을 정상적인 HttpResponse로 반환
  • Spring의 RestTemplate / WebClient와 다른 동작 방식

해결 — HttpResponse의 statusCode 직접 확인

private void sendPushNotificationInternal(WebPushSubscription subscription, String title, String body) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { nl.martijndwars.webpush.Notification notification = createNotification(subscription, title, body); // HttpResponse 받기 org.apache.http.HttpResponse response = pushService.send(notification); int statusCode = response.getStatusLine().getStatusCode(); // statusCode를 직접 확인 if (statusCode == 410) { // 410 Gone - 구독 만료 deleteExpiredSubscription(subscription.getId()); log.warn("410 Gone - 만료된 웹 구독 삭제: endpoint={}", subscription.getEndpoint()); return; } if (statusCode == 404) { // 404 Not Found - 구독 없음 deleteExpiredSubscription(subscription.getId()); log.warn("404 Not Found - 존재하지 않는 웹 구독 삭제: endpoint={}", subscription.getEndpoint()); return; } if (statusCode >= 200 && statusCode < 300) { // 성공 (2xx) log.debug("웹 Push 전송 성공 - statusCode: {}, endpoint: {}", statusCode, subscription.getEndpoint()); return; } // 기타 에러 log.error("웹 Push 전송 실패 - statusCode: {}, endpoint: {}, reason: {}", statusCode, subscription.getEndpoint(), response.getStatusLine().getReasonPhrase()); }

HTTP 클라이언트별 에러 처리 비교

라이브러리 4xx/5xx 처리 방식 410 확인 방법
Spring RestTemplate 예외 던짐 catch (HttpClientErrorException e)e.getStatusCode() == 410
Spring WebClient 예외 던짐 catch (WebClientResponseException e)e.getStatusCode() == 410
Firebase Admin SDK 예외 던짐 catch (FirebaseMessagingException)errorCode == UNREGISTERED
OkHttp 예외 안 던짐 response.code() == 410 직접 확인
nl.martijndwars.webpush 예외 안 던짐 response.getStatusLine().getStatusCode() == 410 직접 확인
// IOException catch는 진짜 네트워크 에러용 catch (IOException e) { log.error("네트워크 에러", e); // 연결 실패, 타임아웃 등 } // HTTP 에러는 statusCode로 처리 if (statusCode == 410) { deleteExpiredSubscription(); // 비즈니스 로직 }

결과

  • 만료된 구독에 410 응답 수신 시 DB에서 즉시 자동 삭제
  • 만료 구독으로의 불필요한 재전송 차단
  • 네트워크 에러(IOException)와 HTTP 에러(statusCode)를 분리해 처리

참고 — 410 Gone

HTTP 410 Gone은 요청한 리소스가 서버에서 영구적으로 삭제됐음을 나타내는 클라이언트 에러 응답이다. 클라이언트는 410 응답을 받은 리소스에 대해 요청을 반복하지 않아야 하며, 해당 링크나 엔드포인트는 제거하거나 교체해야 한다. 일시적인지 영구적인지 불명확할 경우에는 404를 사용하는 것이 적절하다.