발행일

Open API 정책 토글의 캐시·만료 상태가 어긋나던 문제를 동기화한 회고

Open API 정책 토글의 캐시·만료 상태가 어긋나던 문제를 동기화한 회고

Open API 관리 화면에 정책 관리 페이지를 붙이면서, 토글과 만료 상태가 화면과 실제 동작 사이에서 어긋나는 두 가지 stale 문제를 같이 잡았습니다. 둘 다 "분명히 바꿨는데 화면은 옛날 상태" 라는, 운영자가 가장 헷갈려 하는 종류의 버그였어요.


1. 배경 — constance 정책을 화면에서 즉시 바꾸기

토큰 만료일, 분당/일일 호출 제한, 비활성 오퍼레이션 차단 캐시 TTL 같은 값은 constance로 런타임 조정이 가능합니다. 이걸 코드 배포 없이 만지도록 web_admin에 정책 관리 페이지를 만들었어요.

class OpenApiPolicy(ConfigForm):
    # data_source 는 의도적으로 제외 — get_data_provider() 가 settings 만 읽으므로
    # 폼에 노출하면 편집해도 반영되지 않는 dead 설정이 된다.
    field_names = [
        OPEN_API_POLICY.token_expiry_days.name,
        OPEN_API_POLICY.rate_limit_per_minute.name,
        OPEN_API_POLICY.rate_limit_per_day.name,
        OPEN_API_POLICY.operation_cache_ttl.name,
    ]

여기서 작은 결정 하나를 먼저 했습니다. OPEN_API_DATA_SOURCEget_data_provider()가 settings만 읽기 때문에, 폼에 올려봤자 편집해도 무효한 죽은 필드가 됩니다. 그래서 일부러 노출하지 않았어요. 조정 가능한 척하는 UI가 조정 안 되는 것보다는 차라리 없는 게 낫다 는 판단이었습니다.


2. 문제 ① — 토글했는데 TTL만큼 반영이 늦음

비활성 오퍼레이션 차단은 매 요청마다 DB를 보지 않으려고 캐시를 씁니다(OPEN_API_OPERATION_CACHE_TTL, 기본 300초). 그런데 이 캐시 때문에 관리자가 오퍼레이션을 끄거나 켜도 최대 TTL만큼 반영이 늦는 갭이 생겼어요.

운영자 입장에서는 "방금 차단했는데 왜 아직 200이 떨어지지?" 가 됩니다. 보안성 설정이 지연 반영되는 건 특히 위험했어요.

해결은 단순합니다. 토글하는 순간 해당 캐시를 즉시 무효화하는 거예요. 그리고 이게 정말 즉시 반영되는지 테스트로 못 박았습니다.

def test_admin_toggle_invalidates_cache(self):
    """관리자 토글이 미들웨어 차단 캐시를 즉시 무효화한다 — TTL 대기 없이 반영."""
    # 활성 상태로 요청해 캐시를 데운다
    resp = self.client.get("/api/v1/datasets/", HTTP_AUTHORIZATION=f"Token {self.token.key}")
    self.assertEqual(resp.status_code, 200)

    web.post(f"/web-admin/open-api/spec/{tag.pk}/operation/{op.pk}/toggle")

    # cache.clear() 없이도 즉시 차단되어야 한다
    resp = self.client.get("/api/v1/datasets/", HTTP_AUTHORIZATION=f"Token {self.token.key}")
    self.assertEqual(resp.status_code, 404)

"캐시를 데우고 → 토글하고 → 곧장 다시 호출" 하는 시나리오를 그대로 테스트로 옮겼어요. cache.clear() 없이 404가 떨어져야 통과합니다.


3. 문제 ② — 만료된 토큰인데 신청은 "승인됨"

두 번째는 더 미묘했습니다. 토큰이 만료되면 인증 시점에 신청 상태를 EXPIRED로 전이시키고 있었는데, 함정은 API를 더 이상 호출하지 않는 사용자였어요. 인증이 다시 일어나지 않으니 전이 트리거가 안 걸리고, 신청 현황은 영원히 "승인됨" 으로 남았습니다.

즉, 인증 시점 전이만으로는 보이는 상태(승인됨)실제 상태(만료) 가 벌어집니다.

고려한 네 가지

방법장점포기하는 것판단
① 주기적 배치(크론)로 만료 전이조회 경로가 깨끗함운영 대상이 하나 늘어남. 크론이 죽으면 조용히 안 돎. 주기 사이의 갭도 남음
② 상태를 저장하지 않고 조회 때 계산stale 자체가 불가능status가 DB 컬럼이라 필터·정렬·집계가 전부 계산식이 됨. 신청 목록의 상태별 필터가 무너짐
③ 만료 시각에 예약 작업 등록정확한 시점 전이큐·스케줄러 인프라가 필요. 토큰 만료 하나 때문에 들일 규모가 아님
④ 목록 진입 시점에 동기화인프라 추가 0. 보는 순간엔 항상 최신조회 요청이 쓰기를 유발✓ 채택

②가 원칙적으로는 제일 깨끗합니다. 파생 상태를 저장하지 않으면 어긋날 것도 없으니까요. 그런데 status는 이미 DB 컬럼이고 목록 필터·관리자 검토 화면이 전부 그 컬럼을 씁니다. 계산식으로 바꾸면 stale 하나 잡으려고 조회 화면 전체를 다시 짜야 했어요.

①은 규모 대비 과했습니다. 크론을 하나 더 두면 그게 도는지 감시하는 일이 따라오고요.

④를 고른 기준은 "언제 정확해야 하는가" 였습니다. 이 상태가 틀렸을 때 문제가 되는 순간은 누군가 그 목록을 볼 때뿐이에요. 아무도 안 보는 동안 DB가 옛 상태인 건 실질적으로 무해합니다. 그러면 보는 순간에 맞추는 게 가장 적은 비용으로 충분한 정확도를 얻는 방법이었습니다.

목록을 보는 순간이 곧 "현황을 확인하는 순간"이니, 그때 만료분을 정리하면 화면이 항상 진실을 보여줍니다.

class UserTokenRequestView(LoginRequiredMixin, ListView):
    def get_queryset(self):
        # 만료된 토큰의 APPROVED 신청을 EXPIRED 로 동기화 — 인증 시점 전이만으로는
        # API 를 쓰지 않는 사용자의 신청 현황이 "승인됨"으로 남는다.
        ApiTokenRequest.sync_expired()
        return ApiTokenRequest.objects.filter(user=self.request.user).order_by("-requested_at")

관리자 신청 검토 목록에도 같은 sync_expired()를 넣었습니다. 두 진입점 모두를 테스트로 고정했어요.

def test_admin_request_list_syncs_expired(self):
    """관리자 신청 목록 진입 시 만료 토큰의 APPROVED 신청이 EXPIRED 로 전이된다."""
    token = _make_token(self.user, expired=True)
    req = self._approved_request(token)
    resp = web.get("/web-admin/open-api/token-request/")
    req.refresh_from_db()
    self.assertEqual(req.status, ApiTokenRequest.Status.EXPIRED)

4. 두 문제의 공통점 — "보이는 상태 = 실제 상태"

따로 보면 하나는 캐시 무효화, 하나는 상태 전이라 무관해 보여요. 하지만 본질은 같았습니다.

문제어긋난 두 상태동기화 시점
토글 캐시차단 캐시 vs DB의 is_enabled토글하는 순간
만료 신청신청 현황 표시 vs 토큰 실제 만료목록 진입 순간

둘 다 "상태를 바꾸는 사건"과 "상태를 보여주는 사건"이 다른 타이밍에 일어나서 벌어진 갭이었어요. 그래서 해결도 같은 모양 — 상태가 바뀌거나 보여지는 바로 그 순간에 동기화를 끼워 넣기 — 였습니다.


5. 한계 — 조회가 쓰기를 유발한다

채택한 방법의 대가를 분명히 적어둡니다.

sync_expired()는 GET 요청 안에서 UPDATE를 돌립니다. 조회는 부작용이 없어야 한다는 원칙을 어긴 거예요. 지금은 문제가 안 되지만, 조건이 세 가지 중 하나라도 바뀌면 걸립니다.

  • 목록 조회가 잦아지면 — 매 조회마다 만료 대상을 훑는 쿼리가 붙습니다. 지금은 신청 건수가 적어 감당되지만 선형으로 늘어나요.
  • 동시 요청이 겹치면 — 두 사용자가 같은 순간 목록을 열면 같은 행을 두 번 갱신할 수 있습니다. 결과는 같지만(멱등) 불필요한 경합이에요.
  • 읽기 전용 복제본을 붙이면 — 조회를 읽기 DB로 보내는 순간 이 코드가 깨집니다.

지금 규모에서는 적은 비용으로 충분한 정확도라 이 선택이 맞다고 보지만, 위 조건 중 하나가 오면 크론(①) 쪽으로 옮기는 게 맞습니다. 그때를 위해 전이 로직을 ApiTokenRequest.sync_expired()라는 호출 지점과 독립된 클래스 메서드로 빼뒀어요. 크론이든 관리 커맨드든 같은 걸 부르면 됩니다.

캐시 무효화 쪽에도 남은 게 있습니다. 지금은 토글 액션에서만 무효화하는데, 오퍼레이션이 다른 경로(적재 커맨드, 직접 DB 수정)로 바뀌면 캐시는 여전히 TTL을 기다립니다. 모델 시그널로 옮기면 경로와 무관해지지만, 그건 정본과 파생 캐시를 시그널로 붙인 작업에서 겪었듯 다른 종류의 복잡도를 데려옵니다.


6. 교훈

  • 캐시는 "언제 무효화하느냐"가 본체입니다. TTL에만 의존하면 보안성 변경이 지연 반영돼요. 상태를 바꾸는 액션에 무효화를 같이 묶어두는 게 안전합니다.
  • 이벤트 기반 전이는 "이벤트가 안 일어나는 사용자"를 놓칩니다. 인증·호출 같은 트리거가 끊긴 대상은 영원히 옛 상태로 남아요. 조회 시점에 한 번 맞추는 보정을 같이 두면 화면이 거짓말을 안 합니다.
  • 편집해도 무효한 설정은 UI에 올리지 않습니다. 조정 가능한 척하는 필드는 운영자를 속여요.
  • stale은 거의 항상 테스트로 재현 가능합니다. "데우고 → 바꾸고 → 곧장 본다"를 그대로 테스트로 옮기면, 회귀를 막을 수 있어요.

운영자가 화면을 믿을 수 있다는 건 생각보다 큰 가치였습니다. "바꿨으면 즉시 그렇게 보인다" 는 작은 약속을 지키려고 이 두 군데에 동기화 한 줄씩을 넣은 셈이에요.