- 발행일
운영 전에 정해 달라고 적어 둔 목록을, 바꿀 수 있는 기본값으로 닫을 수 있는 만큼 닫았다 — 접근 로그 보존·배포 점검 경고·프록시 호스트
운영 전에 정해 달라고 적어 둔 목록을, 바꿀 수 있는 기본값으로 닫을 수 있는 만큼 닫았다
9월 15일에 Open API 작업을 백엔드에 넘기면서 인수인계 문서를 썼습니다. 문서 2절이 "백엔드에 요청할 것"이고, 맨 위 🔴 칸에는 "운영 전에 반드시 — 안 정하면 응답이 틀리게 나간다"는 항목이 셋 있었어요.
| 항목 | 정해 줘야 하는 것 | |
|---|---|---|
| ① | RDF 어휘의 공식 네임스페이스 주소 | 공식 IRI. dev 값은 로컬 주소다 |
| ② | RDF 안의 항목 주소 규칙 | 지금처럼 pk 주소로 갈지, 안정 식별자로 갈지 |
| ③ | HTTPS 프록시 설정 | 운영 프록시 구성과 넘겨주는 헤더 |
그 아래 🟡·🟢 칸에도 운영에 영향이 있는 항목이 더 있었습니다. 호출 한도 캐시(⑮), 인증 실패 요청의 로그(⑰), 접근 로그의 요청 IP(⑱) 같은 것들이에요.
한 달 반이 지난 10월 28일에도 ①의 공식 주소와 ③의 프록시 헤더는 정해지지 않은 상태였습니다. 그날 저는 이 목록에서 값을 정할 사람이 나중에 바꿀 수 있는 기본값까지만 넣었어요. 값을 못 박지 않으면서도 운영에 나갈 때 같은 함정을 밟지 않게 하는 선이었습니다. ②는 같은 날 v1 주소를 그대로 두고 v2에만 UUID 주소를 더했고, 고정 도메인을 쓸지는 그대로 남겼는데, 그 이야기는 Open API 응답 id를 pk에서 UUID로 바꾸며 v1과 v2를 함께 열었다2026-10-28Open API 응답 id를 pk에서 UUID로 바꾸며 v1과 v2를 함께 열었다 — 호환 깨짐을 버전으로 내보내기Open API 목록이 내보내던 id는 DB의 pk였습니다. pk는 dev와 운영이 다르고 재적재에 밀리는 값이라, 연계 기관이 저장해 두기엔 맞지 않았어요. 바뀌지 않는 공개 식별자(UUID)를 더하고, 정수 id를 기대하는 기존 호출이 깨지지 않게 /api/v2/를 따로 열어 v1과 함께 운영하기로 했습니다. 같은 뷰를 두 주소에 붙이고, 폐기 예고 헤더와 중지 410을 미들웨어 한 곳에서 내보냈습니다. 같은 날 RDF 안의 주소가 여전히 pk였다는 걸 발견해 다시 고쳤고, 시드와 미러는 아직 UUID를 옮기지 못합니다.에 따로 적었습니다. 이 글은 나머지 두 커밋(b6e6ee00·b72df737)입니다.
틀린 키로 부르면 로그만 쌓였다(⑰)
Open API의 인증·호출 한도·접근 로그는 6월 설계 때 같은 회사 다른 시스템의 Open API를 그대로 옮겨 오기로 한 틀입니다. 그 틀의 호출 한도는 토큰 단위로 셉니다.
def get_cache_key(self, request, view):
from open_api.models import ApiToken
if not isinstance(request.auth, ApiToken):
return None
return self.cache_format % {
"scope": self.scope,
"ident": request.auth.pk,
}
인증된 토큰이 없으면 키가 None이고, DRF는 키가 None이면 한도 검사를 건너뜁니다. 게다가 DRF는 인증을 한도보다 먼저 검사해서, 헤더가 없거나 키가 틀렸거나 만료·회수된 요청은 한도에 닿기 전에 401로 끝나요. 그런데 접근 로그 믹스인은 dispatch에서 응답마다 한 행씩 쓰니까 401도 남깁니다. 틀린 키로 계속 부르면 한도에는 안 걸리고 로그 테이블만 끝없이 커지는 구조였어요. 인수인계 문서에 "IP 기준 한도를 둘지, 로그 보존 기간과 정리 방법을 정해 달라"고 적어 둔 게 이 항목입니다.
IP 기준 한도는 이번에 하지 않았습니다. 요청 IP를 어느 헤더에서 읽을지(⑱)가 먼저 정해져야 해요. 지금은 X-Forwarded-For의 첫 값을 쓰는데, 첫 값은 부르는 쪽이 헤더에 직접 넣을 수 있는 값이라 그대로 한도 키로 쓰면 값을 바꿔 가며 한도를 피할 수 있습니다. 대신 쌓이는 쪽에 끝을 두었습니다. 보존 일수 설정과 정리 명령이에요.
class Command(BaseCommand):
help = "보존 기간이 지난 Open API 접근 로그를 지운다. 틀린 키로 반복 호출해도 401 이 매번 쌓이므로 cron 으로 돌린다."
def add_arguments(self, parser):
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--dry-run", action="store_true")
group.add_argument("--apply", action="store_true")
parser.add_argument(
"--days",
type=int,
default=settings.OPEN_API_ACCESS_LOG_RETENTION_DAYS,
help="이 일수보다 오래된 로그를 지운다",
)
지우는 명령이라 --dry-run과 --apply 중 하나를 반드시 고르게 했습니다(상호 배타 그룹, required=True). 아무 옵션 없이 실행하면 지우지도 세지도 않고 오류로 끝나요. --days에 0이나 음수를 넣어도 max(options["days"], 1)로 하루는 남깁니다.
보존 기본값은 365일입니다. 같은 날 넣은 API 버전 관리 목록이 버전마다 최근 30일 호출 수를 접근 로그에서 세기 때문에, 보존 기간이 30일보다 짧으면 그 숫자가 틀어져요. 365일이라는 값 자체는 제가 기본값으로 둔 것이고, 운영에서 얼마나 둘지는 설정으로 바꿀 수 있습니다.
정해지지 않은 값은 고치지 않고 경고하게 했다(①)
①은 제가 정할 수 있는 값이 아니었습니다. RDF의 종류·속성 주소는 DB에 적재된 네임스페이스 주소로 만들어지는데, 설정 기본값이 로컬 주소라서, 저장소 밖의 운영 환경 파일에 값을 넣지 않으면 그대로 쓰입니다. 공식 주소가 정해지지 않은 채 배포하면 운영 응답의 RDF에 로컬 주소가 그대로 찍힙니다.
그래서 값을 넣는 대신 배포 점검이 알리게 했어요. Django의 시스템 체크에 deploy=True로 등록하면 python manage.py check --deploy에서만 돕니다. dev에서는 로컬 주소가 정상이라 평소 check에서는 조용해야 했어요.
@register(Tags.security, deploy=True)
def open_api_deploy_checks(app_configs, **kwargs):
"""운영에 그대로 나가면 응답이 틀리는 설정 — `check --deploy` 에서만 본다(dev 는 로컬 주소가 정상)."""
경고는 둘입니다. 어휘 주소가 로컬 주소면 open_api.W001, 기본 캐시가 프로세스별 LocMemCache면 open_api.W002예요. 경고 문구의 힌트에는 "공식 IRI를 운영 환경 파일에 넣고, 이미 적재된 네임스페이스 행도 확인할 것"까지 적었습니다. 환경변수만 넣고 적재된 행을 그대로 두면 응답은 여전히 틀리기 때문이에요.
W002가 가리키는 캐시(⑮)는 같은 커밋에서 운영 기본값을 DB 캐시로 바꿨습니다(Redis로 갈지는 그대로 남김). 호출 한도와 버전 상태 캐시가 워커마다 따로였던 문제고, 자세한 건 UUID 글에 있어요. 그런데 DB 캐시는 테이블이 있어야 동작하니, 배포 스크립트(migrate.sh)에 createcachetable 단계를 같이 넣었습니다. 캐시 설정만 바꾸고 이 단계를 빠뜨리면 운영에서 캐시를 쓰는 첫 요청부터 테이블이 없다는 오류가 납니다.
프록시 호스트는 기본값으로 켰다(③)
③은 반쯤 진행돼 있었습니다. 9월 28일에 팀원이 운영 설정에 SECURE_PROXY_SSL_HEADER를 넣어, 프록시 뒤에서도 https로 판단하게 해 뒀어요. 남은 건 호스트였습니다. 목록 응답의 url·next, RDF 항목 주소, 명세서의 호출 예제 주소가 모두 request.build_absolute_uri()나 get_host()로 만들어지는데, 프록시가 넘긴 원래 호스트를 읽지 않으면 프록시 안쪽 호스트가 찍힙니다. 특히 호출 예제는 복사해서 그대로 실행하는 주소라 틀리면 가장 먼저 눈에 띄어요.
USE_X_FORWARDED_HOST = env.bool("USE_X_FORWARDED_HOST", default=True)
운영이 프록시 뒤에 있다는 전제는 9월에 팀원이 SECURE_PROXY_SSL_HEADER를 넣으면서 이미 세워 뒀습니다. 팀원 문서에는 그 전제가 성립할 조건으로 "신뢰하는 Nginx가 헤더를 직접 덮어쓰고, 앱 포트로의 외부 직접 접근을 막을 것"이 적혀 있어요. 호스트 헤더도 같은 조건에서만 믿을 수 있어서 같은 전제 위에 기본값을 켰고, 조건이 깨져도 받은 호스트는 ALLOWED_HOSTS로 다시 걸러지는 것을 확인했습니다. 운영 설정은 ALLOWED_HOSTS가 비었거나 와일드카드면 시작 단계에서 오류를 내도록 이미 돼 있습니다. 프록시 없이 직접 받는 구성이면 환경변수로 끌 수 있게 했어요.
그날 닫은 것과 남은 것
| 항목 | 10월 28일 | 남은 것 |
|---|---|---|
| ① 어휘 공식 주소 | 배포 점검 경고(W001) | 공식 IRI 값과 운영 DB의 적재 행 확인 |
| ③ HTTPS 프록시 | 호스트 헤더 신뢰를 기본값으로 켬 | 운영 프록시가 두 헤더를 직접 덮어써서 넘기는지 확인 |
| ⑮ 호출 한도 캐시 | 운영 기본 캐시를 DB 캐시로, 캐시 테이블 생성 단계 추가 | Redis로 갈지는 미정 |
| ⑰ 인증 실패 로그 | 보존 365일 + 정리 명령 | IP 기준 한도는 ⑱이 정해진 뒤 |
인수인계 문서의 각 항목 끝에는 그날 한 일과 아직 정해지지 않은 것을 🔄 표시로 붙였습니다. 항목은 지우지 않고 남겨 뒀습니다.
한계
- 정리 명령은 아직 아무도 돌리지 않습니다. 개발 문서에는 "운영은 cron으로 하루 한 번
--apply를 돌린다"고 적었지만, cron 등록은 배포 환경 쪽 일이라 저장소에는 없어요. 명령을 만든 것과 로그가 실제로 줄어드는 것 사이에 아직 한 단계가 남아 있습니다. - 한 번에 지웁니다. 대상 쿼리셋을
delete()한 번으로 지워서, 처음 돌릴 때 쌓인 로그가 많으면 긴 트랜잭션이 됩니다. 수백만 행 규모가 되면 pk 범위로 나눠 지워야 해요. - 테스트가 경계를 재지는 않습니다. 400일 된 로그와 방금 쓴 로그를 하나씩 두고,
--dry-run은 아무것도 안 지우고--apply는 오래된 쪽만 지우는지 봅니다. 커밋 메시지에는 "경계 삭제 테스트"라고 적었지만 365일 바로 앞뒤나--days옵션을 확인하는 테스트는 없어요. - 경고는
check --deploy를 돌려야 보입니다. 배포 절차에 이 명령이 들어가 있지 않으면 W001·W002는 아무에게도 보이지 않아요. 인수인계 문서의 배포 전 확인 목록에는 9월부터 이 명령이 있었지만, 실행을 강제하는 장치는 아닙니다. - 설정 쪽 변경에는 테스트가 없습니다. W001·W002 경고, 프록시 호스트 기본값, DB 캐시 전환은 테스트 없이 문서와 코드 읽기로만 확인했습니다.