# 코드에서 피드를 페이지 단위로 읽고 응답을 캐시하려면 어떻게 합니까?

전송과 거래 · 커서 루프, 조건부 GET, max-age를 폴링 간격으로 읽는 법 — 네 가지 언어로

*API 가이드 · 8분 · 중급 · 개발자 · cursor · ETag · Cache-Control*

세 가지 습관이며, 각각 몇 줄이면 됩니다. **페이지 이동**은 커서가 없는 페이지가 올 때까지 같은 쿼리를 `cursor=<next_cursor>`와 함께 반복하는 방식으로 합니다. 순회 도중에 필터를 바꾸면 안 됩니다. **재검증**은 `If-None-Match: <etag>`로 합니다. 일치하는 응답은 본문 없는 `304`입니다(`/v1/networks`가 2026-10-24에 `Age: 140`과 함께 이렇게 응답했습니다). **`max-age` 간격으로 폴링**합니다. 피드는 15초, 동기화 상태는 5초, 판정은 60초(순회가 중단된 경우 10초), 레지스트리는 300초, 순위는 3,600초입니다. 더 자주 폴링해도 같은 바이트가 돌아옵니다. 여기의 모든 규칙은 스캐너가 보내는 헤더에서 나온 것이며, 코드가 그것을 보여 줍니다.

## 페이지 이동: `next_cursor` 따라가기

[피드](https://achivx.kr/academy/courses/following-the-stablecoins/following-the-stablecoins-01-settlement-tables/), 동결 기록 원장, [제안](https://achivx.kr/academy/guides/issuer-freezes/) 목록은 *최신순으로, 한 페이지씩* 제공됩니다. 커서는 불투명한 값이며, 유일한 규칙은 정확히 같은 필터와 함께 되돌려 보내는 것입니다. 2026-10-24에 `chain=1&limit=3`의 두 번째 페이지에는 `next_cursor`와 `prev_cursor`가 모두 있었고, `total`은 첫 페이지에만 있었습니다.

**curl**

```
cursor=
while :; do
  page=$(curl -s "https://stablescan.achivx.com/v1/transfers?chain=1&recipient=$ADDR&limit=200${cursor:+&cursor=$cursor}")
  echo "$page" | jq -r '.items[] | "\(.block_time) \(.payer) \(.amount.amount) \(.amount.token)"'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
  sleep 0.1
done
```

**Python**

```
def pages(path, **params):
    '''yield every page of a cursor-paged route; stop at the page without next_cursor'''
    cursor = None
    while True:
        page = scan(path, **params, **({"cursor": cursor} if cursor else {}))
        yield page
        cursor = page.get("next_cursor")
        if not cursor:
            return

received = [row for page in pages("/v1/transfers", chain=1, recipient=addr, limit=200) for row in page["items"]]
```

**Go**

```
func allTransfers(q url.Values) ([]transfer, error) {
	var out []transfer
	for {
		var page struct {
			Items      []transfer `json:"items"`
			NextCursor string     `json:"next_cursor"`
		}
		if err := get("/v1/transfers", q, &page); err != nil {
			return out, err
		}
		out = append(out, page.Items...)
		if page.NextCursor == "" {
			return out, nil
		}
		q.Set("cursor", page.NextCursor)
	}
}
```

**TypeScript**

```
async function* pages<T extends { next_cursor?: string }>(path: string, params: Params) {
  let cursor: string | undefined;
  do {
    const page = await scan<T>(path, { ...params, cursor });
    yield page;
    cursor = page.next_cursor;
  } while (cursor);
}
for await (const page of pages<Page>("/v1/transfers", { chain: 1, recipient: addr, limit: 200 })) process(page.items);
```

멀리 순회하기 전에 알아 둘 점이 두 가지 있습니다. `hot_window_start`가 하한입니다. 그보다 오래된 행은 보관 처리되며, [순회](https://achivx.kr/academy/reference/glossary/#g-walk)는 기록의 시작이 아니라 그 지점에서 끝납니다. 또한 첫 페이지의 `total`은 25,000에서 [상한](https://achivx.kr/academy/reference/limits/)이 걸립니다(`"capped": true`). 순회는 개수가 말하는 것보다 길 수 있습니다. 저장해 둔 커서에서 `dir=newer`로 순회하는 것이 폴러가 따라잡는 방법입니다. 멈춘 지점부터 앞으로 페이지를 넘깁니다.

## 재검증: `If-None-Match`

모든 GET 응답에는 `ETag`가 있습니다(`Access-Control-Expose-Headers: ETag`도 있으므로 브라우저 클라이언트도 읽을 수 있습니다). 이를 되돌려 보내면 일치하는 응답은 본문 없는 `304 Not Modified`입니다.

```
curl -s -D - -o /dev/null -H 'If-None-Match: "8fc4bafb57ec938fe40dc8793281336e"' https://stablescan.achivx.com/v1/networks
HTTP/2 304
etag: "8fc4bafb57ec938fe40dc8793281336e"
cache-control: public, max-age=300, stale-while-revalidate=60, stale-if-error=3600
```

태그는 본문에서 만들어지므로, 본문이 같을 때 일치합니다. 레지스트리, `/v1/sanctions`, [`/v1/methodology`](https://stablescan.achivx.com/v1/methodology), 재생된 판정이 그렇습니다(제재 대상 예시의 위험 응답은 두 번째 조회에서 같은 `etag`와 `Age: 2`로 응답했습니다. 오래된 태그로 한 첫 조회는 `computed_at`이 바뀌었기 때문에 `200`으로 응답했습니다). `freshness_seconds`가 매초 바뀌는 주소 개요는 거의 일치하지 않습니다. 엣지가 약한 태그(`W/"…"`, Markdown 페이지에서 확인됨)를 줄 수도 있으며, 이것도 일치합니다.

**Python**

```
import urllib.error

def scan_cached(path, cache, **params):
    '''cache: {url: (etag, body)}; a 304 returns the cached body'''
    url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "")
    headers = {"Accept": "application/json"}
    if url in cache:
        headers["If-None-Match"] = cache[url][0]
    req = urllib.request.Request(url, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            body = json.load(r); cache[url] = (r.headers.get("ETag"), body); return body
    except urllib.error.HTTPError as e:
        if e.code == 304:
            return cache[url][1]
        raise
```

**Go**

```
// etags caches the last body per URL; a 304 returns it.
var etags = map[string]struct {
	tag  string
	body []byte
}{}

func getCached(u string) ([]byte, error) {
	req, _ := http.NewRequest(http.MethodGet, u, nil)
	if c, ok := etags[u]; ok {
		req.Header.Set("If-None-Match", c.tag)
	}
	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode == http.StatusNotModified {
		return etags[u].body, nil
	}
	body, err := io.ReadAll(res.Body)
	if err == nil && res.StatusCode == 200 {
		etags[u] = struct {
			tag  string
			body []byte
		}{res.Header.Get("ETag"), body}
	}
	return body, err
}
```

**TypeScript**

```
const etags = new Map<string, { tag: string; body: unknown }>();
async function scanCached<T>(url: string): Promise<T> {
  const hit = etags.get(url);
  const res = await fetch(url, { headers: hit ? { "if-none-match": hit.tag } : {} });
  if (res.status === 304 && hit) return hit.body as T;
  const body = (await res.json()) as T;
  const tag = res.headers.get("etag");
  if (res.ok && tag) etags.set(url, { tag, body });
  return body;
}
```

## 폴링: `Cache-Control`을 간격으로 읽기

| 경로 | 2026-10-24의 `max-age` | 폴링 간격 |
|---|---|---|
| `/v1/sync/status` | 5 | 5초 |
| `/v1/transfers`, `/v1/verify` | 15 | 15초 |
| `/v1/freezes/acts`, `/v1/freezes/proposals` | 30 | 30초 |
| `/v1/addresses/{a}/balances` | 30 | 30초 |
| 주소 개요, `/risk`, `/exposure`, `/graph`, `/walk`, `/volume`, `/approvals`, `/heuristics`, `/v1/stats/home`, `/v1/stats/overview`, `/v1/freezes/networks`, `/v1/search` | 60 | 1분(`/risk`와 `/exposure`: [순회](https://achivx.kr/academy/reference/glossary/#g-walk)가 중단된 경우 10) |
| `/v1/sanctions`, `/v1/subscriptions`, `/v1/freezes/guide`, `/v1/tokens/{t}/freezes` | 300 | 5분 |
| `/v1/networks`, `/v1/tokens`(`stale-while-revalidate=60, stale-if-error=3600` 포함) | 300 | 5분 |
| `/v1/entities…` | 600 | 10분 — 어차피 하루에 한 번 생성됨(`built_at`) |
| `/v1/freezes?tx=`, `/v1/tokens/{a}/contract`, `/v1/tokens/{t}/routes`, `/addresses` | 3600 | 1시간 |
| `/v1/stats/slo`, `/health`, `/ready`, 모든 오류, 일부가 빠진 모든 응답 | `no-store` | — |

`max-age`보다 자주 폴링한 판정은 같은 `computed_at`을 돌려줍니다. 15초보다 자주 폴링한 피드는 같은 페이지를 돌려줍니다. 재생된 응답의 `Age` 헤더는 사본이 얼마나 오래되었는지 알려 줍니다. `200`에 붙은 `no-store`가 무엇을 요구하는지(잠시 기다린 뒤 다시 요청)는 [응답 읽기](https://achivx.kr/academy/api/reading-an-answer/)에서 다룹니다.

## Stablecoin scanner ACHIVX에서

- `next_cursor`, `total`, `hot_window_start`가 있는 페이지: [/v1/transfers?chain=1&limit=5](https://stablescan.achivx.com/v1/transfers?chain=1&limit=5).
- ETag로 재검증되는 응답: [/v1/networks](https://stablescan.achivx.com/v1/networks). 매뉴얼의 규칙: [/agents.md](https://stablescan.achivx.com/agents.md) §6.

## 출처

- [에이전트 매뉴얼 — agents.md(버전 2026-10-05)](https://stablescan.achivx.com/agents.md)
- [REST 인터페이스의 OpenAPI 3.1 명세(API 1.4.0)](https://stablescan.achivx.com/openapi.json)
- [2026-10-24에 읽은 응답 헤더 — ETag가 붙은 /v1/networks와 304](https://stablescan.achivx.com/v1/networks)

## 더 알아보기

- [페이지 이동과 캐시는 어떻게 합니까: 커서, ETag, If-None-Match](https://achivx.kr/academy/for-agents/how-to-page-and-cache/)
- [가이드: 정산 테이블](https://achivx.kr/academy/guides/settlement-tables/)

## 관련 질문

- [정산 피드를 읽고 주소, 토큰, 블록으로 필터링하려면 어떻게 합니까?](https://achivx.kr/academy/api/settlement-feed/)
- [코드에서 오류, 요청 한도, 타임아웃을 처리하려면 어떻게 합니까?](https://achivx.kr/academy/api/errors-and-limits-in-code/)
- [레시피: 동결 기록 원장을 폴링하고 새 기록을 알리기](https://achivx.kr/academy/api/recipe-follow-freezes/)

전송과 거래 · cursor · ETag · Cache-Control · 2026-10-24 작성, openapi.json, agents.md 및 당일 실제 호출 기반. 구조는 인용한 것이며, 수치는 날짜 기준이고 인덱스에 따라 바뀝니다.

---

출처: https://achivx.kr/academy/api/paging-and-caching-in-code/ · 영어 원문: https://achivx.com/academy/api/paging-and-caching-in-code.html
