Skip to content

feat: 외부 API 한도를 10% 단계마다 디스코드로 알린다 - #258

Merged
sevineleven merged 5 commits into
devfrom
feat/257-quota-alert
Aug 13, 2026
Merged

feat: 외부 API 한도를 10% 단계마다 디스코드로 알린다#258
sevineleven merged 5 commits into
devfrom
feat/257-quota-alert

Conversation

@sevineleven

Copy link
Copy Markdown
Contributor

Situation

#123 이 한도를 보이게 했지만, 보러 가야 보인다.

/api/v1/quotas 를 아무도 열지 않으면 소진을 여전히 모른다. 로그에 70%·90% warn 이 있지만 그것도 마찬가지다 — 실시간으로 로그를 보고 있는 사람이 없다.

한도가 마르면 조용히 나빠진다. 관광정보가 마른 날 운영 코스가 인허가 폴백으로 내려갔는데, 그걸 안 것은 한참 뒤였다. 폴백은 정상처럼 보이기 때문에 화면만 봐서는 알 수 없다.

가장 위험한 것은 TMAP 경유지 최적화 50이다. 코스 하나가 여러 번 부르면 하루에 코스 몇 개를 못 만든다.

Task

알림을 붙이는 것 자체는 어렵지 않다. 어려운 건 알림이 소음이 되지 않게 하는 것이다.

ExternalApiCallRecorder.record()외부 호출마다 돈다. "70% 넘었다" 로 판정하면 그 뒤 모든 호출이 알림을 쏜다. 며칠이면 아무도 안 보게 되고, 그러면 알림이 없는 것과 같다. 놓치는 것보다 이쪽이 더 나쁘다.

Action

단계가 올라가는 순간에만 보낸다

step = used * 10 / limit 으로 10% 단계를 구하고, 단계가 처음 올라갈 때만 보낸다.

한도가 작을수록 촘촘해진다. 의도한 결과다 — 빡빡한 쪽을 자주 보게 된다.

API 한도 한 단계
TMAP 경유지최적화 50 5건마다
국문관광정보 1,000 100건마다
TAGO 정류소 10,000 1,000건마다

단계 상한은 10이다. 한도를 넘겨도 더 오르지 않는다 — 초과분마다 단계가 오르면 안 고치는 동안 계속 울린다.

중복 방지를 인메모리에 두지 않는다

"어디까지 알렸나" 를 어딘가 기억해야 하는데, 인메모리 플래그는 재배포마다 리셋돼 이미 보낸 단계를 다시 보낸다.

external_api_call 을 애초에 테이블로 둔 것과 같은 함정이다. 그 마이그레이션 주석이 이미 말하고 있다 — "인메모리로 두면 재시작마다 0 이 되어 실제보다 여유 있게 보인다. 배포가 잦은 날일수록 실제 소진에 가까운데 화면은 깨끗해지는, 정확히 반대 방향의 오차가 난다."

notified_step 컬럼을 두고 조건부 UPDATE 로 단계를 선점한다.

UPDATE external_api_call SET notified_step = ?
 WHERE call_date = ? AND api = ? AND notified_step < ?

판정과 기록이 한 문장이다. 읽고 나서 쓰면 그 사이에 다른 인스턴스가 같은 단계를 읽어 둘 다 보낸다. DB 가 행 잠금으로 갈라주게 두면 인스턴스가 몇이든 한 번만 나간다. call_date 가 키라 자정을 넘기면 새 행이 되어 단계도 자연히 리셋된다.

요청 경로를 늦추지 않는다

record() 는 코스 생성 도중에 불린다. 여기서 디스코드 응답을 기다리면 디스코드가 느린 날 사용자 요청이 함께 느려진다. 구독만 걸고 곧바로 돌아온다.

실패는 삼키되 흔적을 남긴다. 알림을 못 보낸 것 때문에 코스 생성이 깨지면 안 되지만, 조용히 죽으면 알림이 없는 것과 같다.

봇이 아니라 웹훅

우리는 보내기만 하고 읽을 것이 없다. 봇은 게이트웨이 연결·토큰·권한이 따라붙는데 얻는 것이 없다.

웹훅
필요한 것 URL 하나 토큰·게이트웨이 연결·권한
할 수 있는 것 보내기 보내기·읽기·명령 응답
우리가 쓰는 것 보내기 보내기

Notifier port 로 나눠 #220(정책 만료 알림)이 그대로 재사용한다 — 통로를 두 번 만들지 않는다.

  • prod 에서만 실제로 보낸다. 로컬에서 돌면 개발 중에 팀 채널이 울린다
  • 로컬은 no-op 이 아니라 로그로 남긴다. 아무것도 안 하면 "언제 무엇이 나가는가" 를 운영에 올려 봐야 알게 되는데, 알림은 울려 봐야 맞는지 아는 기능이다
  • 프로파일이 대칭(prod / !prod)이라 어느 환경에서도 빈이 정확히 하나다
  • URL 이 없어도 부팅은 된다. 시크릿 없이 로컬이 뜨는 불변식을 지킨다

웹훅 URL 이 로그로 샐 자리를 막았다

기존 마스킹은 이름=값 을 찾는데 웹훅 토큰은 경로 조각이라 이름이 없다. 전송 실패 메시지에 URL 이 섞이면 그대로 샜을 자리다 — 이 URL 끝을 아는 사람은 누구나 우리 채널에 글을 쓸 수 있다.

https://discord.com/api/webhooks/123456789/aB3-xY_secret
                                          → /api/webhooks/123456789/***

Result

⚠️ TMAP 경유지최적화 5/50 (10%)
🔴 국문관광정보 1000/1000 (100%) — 한도 소진. 이후 호출은 실패합니다
  • 실제 웹훅으로 전송을 확인했다(HTTP 204). 문구·도착까지 눈으로 봤다
  • 하루 알림 상한은 API 10개 × 10단계. 단계 전환에만 나가므로 그 이상은 나올 수 없다
  • 검증에서 비자명한 것은 "두 번 울리지 않는가" 다. 단계 경계까지 몰아간 뒤 같은 단계에서 더 불러도 조용한지, 이미 알린 단계가 다시 선점되지 않는지를 실제 DB 로 잠갔다

자문 셋 (CLAUDE.md)

  1. 운영에서 버티는가 — 컬럼 1개(TINYINT) 추가. 호출당 UPDATE 한 번이 는다.
  2. 한도를 갉아먹지 않나외부 호출 증가 0. 디스코드는 우리 공공 API 한도와 무관하다.
  3. 코스가 나아지는가 — 간접적이다. 한도가 마르면 코스가 폴백으로 내려가는데, 마르기 전에 알면 막을 수 있다. 사용자 화면이 직접 바뀌지는 않는다.

후속


연관 이슈

- 봇이 아니라 웹훅이다. 우리는 보내기만 하고 읽을 것이 없어, 게이트웨이 연결·토큰·권한이
  따라붙는 봇으로 얻는 것이 없다
- port 로 나눠 #220(정책 만료 알림)이 그대로 재사용하게 한다. 통로를 두 번 만들지 않는다
- prod 에서만 실제로 보낸다. 로컬에서 돌면 개발 중에 팀 채널이 울린다
- 로컬은 no-op 이 아니라 로그로 남긴다. 아무것도 안 하면 "언제 무엇이 나가는가" 를
  운영에 올려 봐야 알게 되는데, 알림은 울려 봐야 맞는지 아는 기능이다
- URL 이 없어도 부팅은 된다 — 시크릿 없이 로컬이 뜨는 불변식을 지킨다
- 기존 규칙은 `이름=값` 을 찾는데 웹훅 토큰은 경로 조각이라 이름이 없어 안 걸렸다.
  전송 실패 메시지에 URL 이 섞이면 그대로 샜을 자리다
- 이 URL 끝을 아는 사람은 누구나 우리 채널에 글을 쓸 수 있다
- #123 이 한도를 보이게 했지만 보러 가야 보인다. 로그의 70/90% warn 도 마찬가지로
  실시간으로 보는 사람이 없어, 한도가 마르면 코스가 폴백으로 내려간 뒤에야 알았다
- record() 는 외부 호출마다 돈다. "70% 넘었다" 로 판정하면 그 뒤 모든 호출이 알림을
  쏘므로, 어디까지 알렸는지를 어딘가 기억해야 한다
- 인메모리 대신 DB 컬럼으로 둔다. 재배포마다 리셋되면 이미 보낸 단계를 다시 보내는데,
  이 테이블을 애초에 DB 로 둔 이유와 같은 함정이다
- 판정과 기록을 조건부 UPDATE 한 문장으로 합친다. 읽고 나서 쓰면 그 사이 다른 인스턴스가
  같은 단계를 읽어 둘 다 보낸다
- 단계 상한을 10 으로 둔다. 초과분마다 오르면 안 고치는 동안 계속 울리고, 며칠이면
  아무도 안 보게 되어 알림이 없는 것과 같아진다
- 단계 계산은 enum 이 소유한다. 한도가 작을수록 촘촘해지는 것이 의도다 — TMAP 50 은
  5건마다, TAGO 10,000 은 1,000건마다 본다
- prod 에서만 실제 전송하므로 운영 환경변수로만 들어간다
- 단계 계산은 경계값을 망라한다. 놓치는 것보다 호출마다 쏘는 쪽이 더 나쁘다 —
  며칠이면 아무도 안 보게 되어 알림이 없는 것과 같아진다
- 이미 알린 단계가 다시 선점되지 않는 것을 DB 로 확인한다. 인메모리였다면 여기서 통과한다
- 웹훅 토큰이 로그로 새지 않는지 잠근다
- 기록기 생성자가 바뀌어 직접 만드는 두 곳을 함께 고친다
@sevineleven sevineleven added the feat 새 기능 (외부에 보이는 변화) label Aug 13, 2026
@sevineleven sevineleven linked an issue Aug 13, 2026 that may be closed by this pull request
16 tasks
@sevineleven sevineleven self-assigned this Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sevineleven, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 78 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c1a46dc-c9fb-43e6-bfe0-d44e7953859d

📥 Commits

Reviewing files that changed from the base of the PR and between 7c8407f and 02c5f45.

📒 Files selected for processing (17)
  • .github/workflows/deploy.yml
  • application-secret.properties.example
  • src/main/java/com/offway/core/common/external/ExternalApi.java
  • src/main/java/com/offway/core/common/external/ExternalApiCallRecorder.java
  • src/main/java/com/offway/core/common/external/ExternalApiCallRepository.java
  • src/main/java/com/offway/core/common/logging/SensitiveParams.java
  • src/main/java/com/offway/core/common/notification/DiscordWebhookNotifier.java
  • src/main/java/com/offway/core/common/notification/DiscordWebhookProperties.java
  • src/main/java/com/offway/core/common/notification/LoggingNotifier.java
  • src/main/java/com/offway/core/common/notification/Notifier.java
  • src/main/resources/application.properties
  • src/main/resources/db/migration/V20260814004500__add_external_api_call_notified_step.sql
  • src/test/java/com/offway/core/common/external/ExternalApiQuotaAlertIntegrationTest.java
  • src/test/java/com/offway/core/common/external/ExternalApiQuotaIntegrationTest.java
  • src/test/java/com/offway/core/common/external/ExternalApiUsageStepTest.java
  • src/test/java/com/offway/core/common/external/NoOpCallRecorder.java
  • src/test/java/com/offway/core/common/logging/SensitiveParamsTest.java

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sevineleven
sevineleven merged commit 080dfd4 into dev Aug 13, 2026
4 checks passed
@sevineleven
sevineleven deleted the feat/257-quota-alert branch August 13, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새 기능 (외부에 보이는 변화)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] common — 외부 API 한도 10% 단계마다 디스코드 알림

1 participant