발행일

Open API 응답 id를 pk에서 UUID로 바꾸며 v1과 v2를 함께 열었다 — 호환 깨짐을 버전으로 내보내기

Open API 응답 id를 pk에서 UUID로 바꾸며 v1과 v2를 함께 열었다

교통 데이터 플랫폼의 Open API는 메시지·데이터 프레임·데이터 엘리먼트 목록을 JSON으로, 상세를 RDF로 돌려줍니다. 목록의 항목마다 id가 있고, 연계 기관은 이 값으로 상세를 부릅니다.

// 예시 값
{ "id": 1532, "name": "...", "url": ".../api/v1/messages/1532/" }

이 1532가 DB의 pk였습니다. 10월 27~28일에 이 값을 바뀌지 않는 공개 식별자로 바꾸고, 그 변경을 /api/v2/로 따로 냈습니다(db0f1a0d). 이 글은 왜 pk를 내보내면 안 됐는지, 왜 v1을 고치지 않고 v2를 열었는지, 그리고 같은 날 놓친 걸 어떻게 다시 잡았는지에 대한 기록입니다.

pk도 OID도 id가 될 수 없었다

바깥에 내보낼 식별자 후보는 세 가지였습니다.

후보문제판단
pk (지금 값)DB 번호다. dev와 운영의 번호가 다르고, 시드로 다시 적재하면 밀린다기각
OID이름을 바꾸면 폐기되고 다시 발급된다. 아직 OID가 없는 항목도 있다기각
새 UUID 칸한 번 정하면 바뀌지 않는다. 대신 칸 하나와 마이그레이션이 든다채택

pk가 재적재에 밀린다는 건 이 시스템에서 이미 여러 번 겪은 일입니다. 데이터 맵 주소를 설계할 때도 pk를 주소에 넣는 안을 같은 이유로 기각했어요. 화면 주소는 우리가 고치면 되지만, Open API의 id는 연계 기관이 자기 DB에 저장하는 값입니다. 우리가 재적재를 한 번 하면 그쪽에 저장된 번호가 전부 엉뚱한 항목을 가리키게 됩니다.

그래서 MetaClassInstance에 public_id(UUID, 유일, 편집 불가)를 더했습니다.

마이그레이션을 세 단계로 나눈 이유

UUID 칸을 더할 때 AddField(default=uuid.uuid4, unique=True) 한 줄로 끝내면 실패합니다. AddField의 default는 함수를 한 번 불러 나온 값 하나를 기존 모든 행에 넣기 때문에, 행이 둘 이상이면 유일 제약에 걸립니다.

operations = [
    # 1. 빈 칸으로 더한다
    migrations.AddField(..., field=models.UUIDField(editable=False, null=True)),
    # 2. 행마다 다른 값을 채운다
    migrations.RunPython(fill_public_ids, migrations.RunPython.noop),
    # 3. 그다음에 유일 제약을 건다
    migrations.AlterField(..., field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
]

dev의 7,440건이 모두 서로 다른 값으로 채워진 것을 확인했습니다.

v1을 고치지 않고 v2를 연 이유

id가 정수에서 UUID 문자열로 바뀌면 호환이 깨집니다. 이미 /api/v1/messages/1532/ 모양으로 부르는 코드가 있다면 그대로 멈춰요. v1을 그대로 바꾸면 연계 기관이 어느 날 갑자기 깨집니다. 그래서 고도화 계획은 처음부터 "호환이 깨지는 변경은 v2로 내고, v1은 폐기를 예고한 뒤 중지한다"로 정해 두었고, 1단계(버전 기록)만 먼저 하고 v2 개설은 그런 변경이 실제로 생길 때로 미뤄 두었습니다. 제가 판단한 건 UUID가 바로 그 첫 호환 깨짐이라는 것, 그래서 1단계(버전 관리)와 2단계(v1·v2 병행)를 한 커밋에 묶은 것입니다.

같은 뷰를 두 주소에 붙였다

v2를 연다고 뷰를 복사하지는 않았습니다. 두 버전의 차이는 id와 상세 주소뿐이라, 뷰를 두 벌 두면 나머지 모든 것(공개 등급 거르기, 검색, 페이지 나누기)이 두 군데서 따로 늙어 갑니다.

open_api/urls_v2.py가 v1과 같은 뷰를 네임스페이스 open_api_v2로 붙이고, 뷰는 요청의 URL 네임스페이스를 보고 세 군데만 바꿉니다.

  • id: v1은 pk 정수, v2는 public_id 문자열
  • 상세 주소: v2는 /api/v2/<유형>/<uuid:public_id>/ — 정수로 부르면 404
  • 목록 시리얼라이저

OID와 토큰 관리 API는 v1과 같습니다. OID 상세 안에 딸려 나오는 항목 목록의 id·url만 v2 규칙을 따릅니다.

명세는 주소마다 따로, 문서는 /api/v2/ 밖에

스키마 하나에는 주 버전 하나만 싣습니다. v1 명세에 v2 경로가 섞이면 연계 기관이 어느 쪽을 써야 하는지 헷갈리거든요. v1 명세는 /api/schema/, v2 명세는 /api/schema/v2/에 두고, 명세의 info.version도 그 주소의 운영 버전을 따라가게 했습니다.

v2 문서를 /api/v2/schema/처럼 API 밑에 두지 않은 데는 이유가 있습니다. 그 prefix는 언젠가 중지(410) 대상이 됩니다. API가 멈춰도 "무엇으로 옮겨 가야 하는지"를 설명하는 명세는 읽혀야 하니, 문서는 버전 prefix 밖에 두었습니다.

폐기 예고와 중지를 미들웨어 한 곳에서

버전마다 상태가 있습니다 — 운영, 폐기 예정, 대체됨, 중지. 이 상태를 응답에 싣는 일은 ApiVersionLifecycleMiddleware 한 곳이 맡습니다.

주소의 상태응답
운영그대로
폐기 예정Deprecation(RFC 9745) · Sunset(RFC 8594) · Link: <옮겨 갈 주소>; rel="successor-version" 헤더를 붙인다
중지뷰에 닿기 전에 410과 옮겨 갈 주소를 돌려주고, 접근 로그에 410으로 남긴다

헤더를 표준 이름으로 붙인 건, 연계 기관 쪽 HTTP 클라이언트나 게이트웨이가 이 헤더를 읽고 경고를 띄울 수 있기 때문입니다. 다만 헤더를 아무도 읽지 않을 수도 있어서, 폐기 예정·중지 버전의 관리 화면에는 "헤더를 읽지 않는 기관에는 따로 연락하라"는 안내를 붙였습니다.

상태는 관리 화면에서 버전을 저장하는 순간 바뀌어야 합니다. 미들웨어는 상태를 60초 캐시하되, 버전을 저장·삭제하면 캐시를 바로 비웁니다. 다만 운영 기본 캐시가 프로세스별(LocMem)이라 비우기가 한 워커에만 닿았고, 같은 날 운영 기본 캐시를 워커끼리 함께 쓰는 DB 캐시로 바꿨습니다(b72df737). 호출 한도가 워커 수만큼 늘어나던 문제도 같이 닫혔어요. 지금 dev에서는 v2를 2.0.0 운영으로, v1의 1.1.0을 폐기 예정(중지 예정일 6개월 뒤)으로 등록해 두었고, 그래서 dev의 v1 응답에는 지금 예고 헤더가 붙습니다.

명세 표출 설정을 v2로 두 배로 늘리지 않았다

관리자는 API마다 명세에 보일지, 호출을 막을지 정할 수 있습니다. 이 설정은 v1 경로와 operationId로 저장되어 있어요. v2를 열면 같은 API가 경로 두 개를 갖게 되는데, 설정을 v2에도 따로 만들면 관리자가 같은 API를 두 번 켜고 꺼야 하고 분류별 API 수도 두 배가 됩니다.

그래서 설정은 v1에만 두고, 요청이 들어올 때 v2 경로를 같은 API의 v1 경로로 바꿔({public_id} → {id}) 같은 설정으로 판단하게 했습니다. 스키마 쪽도 operationId 끝의 _v2를 떼고 같은 비활성 목록과 대조합니다.

같은 날 놓친 것: RDF 안의 주소는 여전히 pk였다

v2를 연 뒤 상세 RDF를 다시 보다가 하나를 놓친 걸 알았습니다(fa20a93f). v2 목록의 JSON id는 UUID인데, 상세 RDF 안에서 항목을 가리키는 노드 주소는 v1과 똑같이 /std-data/dataset/instance/<pk>였어요.

그러면 연계 기관은 한 항목에 대해 식별자 두 개를 받습니다. JSON의 UUID와 RDF 안의 pk 주소요. 어느 쪽을 저장할지 갈리고, pk 주소 쪽을 저장하면 재적재 한 번에 다시 어긋납니다. pk를 내보내지 않으려고 v2를 열었는데 RDF로 pk가 그대로 새고 있었던 거예요.

v2 상세의 노드 주소와 목록의 web_url을 /std-data/dataset/instance/<uuid>로 바꾸고, 이 주소를 열면 기존 pk 상세 화면으로 302로 잇는 뷰를 하나 더했습니다. 공개 여부 판단은 이어진 상세 화면이 그대로 합니다.

v1과 화면의 RDF 다운로드, 관계도는 pk 주소를 그대로 둡니다. 관계도 JS가 노드 id를 pk 주소 모양으로 맞춰 보기 때문에, 화면까지 바꾸면 관계도를 다시 짜야 합니다. 바깥으로 나가는 v2만 바꾸는 게 이번 범위였어요.

UUID가 바뀌지 않게 지키는 일

칸을 더한 것만으로는 부족했습니다. 이 시스템은 시드 파일로 DB를 다시 만드는 일이 있고, 그때 새 UUID가 생기면 pk와 똑같은 문제가 되돌아옵니다(2b8f538c).

  • 평소 적재는 이름으로 기존 행을 찾아 쓰므로 값이 유지됩니다.
  • 시드 덤프 명령이 public_id를 함께 싣고, 적재 명령은 새로 만드는 행에만 시드의 값을 씁니다.
  • 이미 있는 행은 덮어쓰지 않습니다. 연계 기관이 그 값으로 부르고 있을 수 있어서요. 값이 다르면 경고를 남기고 public_id_mismatch로 셉니다.
  • 시드의 값을 다른 항목이 이미 쓰고 있으면, 저장 단계의 유일 제약 오류 대신 어느 행인지 알려 주며 적재를 멈춥니다.

dev의 OID 번호를 운영과 같게 심었던 일과 같은 원칙입니다. 번호를 새로 매기는 것과 원본의 번호를 옮기는 것은 다르고, 바깥에서 보는 식별자는 옮겨야 합니다.

같이 넣은 것: revision과 updated_since

같은 시기에 목록 응답에 칸 두 개를 더했습니다. 둘 다 필드 추가라 호환은 깨지지 않고 v1·v2 모두에 들어갑니다.

revision(155f1fdf)은 데이터의 개정 번호입니다. 상세 화면의 '개정 이력'에 있는 판 번호를 그대로 싣습니다. 모델 필드 이름은 version인데 API에서는 revision으로 냈어요. 같은 응답의 명세에 API 버전(info.version)이 있어서, 둘 다 version이면 헷갈립니다. API 버전은 응답의 모양이 바뀔 때, 개정 번호는 데이터의 내용이 바뀔 때 오릅니다. 관리 메뉴 이름을 그냥 '버전 관리'가 아니라 'API 버전 관리'로 붙인 것도 같은 이유입니다.

revised_at과 ?updated_since=(ea754cec)는 변경분만 받게 해 줍니다. 연계 기관이 매번 전체 목록을 받지 않고 "어제 이후 바뀐 것"만 받을 수 있어요. revised_at은 팀원이 이전에 만들어 둔 '최종 수정 일시' 칸(auto_now)을 그대로 쓴 것입니다. 날짜만 주면 서버 시간대의 그날 0시부터, 시간대 없는 일시도 서버 시간대로 보고, 형식이 틀리면 400을 돌려줍니다.

검증

  • test_version.py 11건 — 버전 등록 규칙(호환 깨짐이 있으면 주 버전이 올라야 저장, 폐기 예정이면 중지 예정일 필수 등)
  • test_api_v2.py 9건 — v1은 정수·v2는 UUID, v2 상세는 UUID로만 찾음, v2 RDF 노드 주소도 UUID이고 열면 상세로 이어짐, 폐기 예정이면 v1에 예고 헤더, 중지되면 410과 옮겨 갈 주소, 저장하면 상태가 바로 바뀜, 명세는 버전마다 따로, 명세서 버전 선택, 명세 표출 비활성이 v2에도 걸림
  • 목록 테스트에 updated_since(열흘 전에 바뀐 항목이 20일 전 기준에는 잡히고 오늘 기준에는 안 잡힘, 없는 날짜·어제 같은 값은 400)와 revision·revised_at 칸 확인, 적재 명령의 public_id 보존 테스트

한계와 남은 것

  • 시드와 미러가 아직 UUID를 옮기지 못합니다. 덤프·적재 명령은 고쳤지만, 지금 저장소에 있는 시드 파일에는 아직 public_id가 없습니다. 운영 데이터를 dev로 옮기는 미러 명령은 저장소에 소스가 없어 손대지 못했어요. 그래서 dev의 UUID는 운영과 다릅니다. v2를 연 뒤 운영 DB는 백업·복원으로만 옮긴다고 문서에 못 박아 두었는데, 이건 규칙으로 막은 것이지 코드로 막은 게 아닙니다.
  • updated_since는 공개·비공개 전환을 잡지 못합니다. 팀원이 이 칸을 만들 때 "내용이 마지막으로 바뀐 시각"으로 정의했고, 등급만 옮기는 심사 전이는 내용 변경이 아니라고 보고 일부러 올리지 않게 해 두었습니다. 그래서 새로 공개되거나 공개에서 빠진 항목은 이 필터로 안 보입니다. 이 칸에 공개 전환까지 넣으면 정의가 바뀌고 OID API의 수정일도 같은 값을 쓰기 때문에, 이번에는 칸의 의미를 그대로 두고 명세 설명과 이용 가이드에 "변경분만 받더라도 주기적으로 전체 목록과 맞추라"고 적어 메웠습니다.
  • RDF 노드 주소를 만들 때 요청마다 전 항목의 pk→UUID 대응을 한 번 읽습니다. dev 기준 7,440행입니다. 지금은 문제가 없지만 항목이 많이 늘면 상세 하나에 필요한 것만 읽도록 좁혀야 합니다.
  • 고정 도메인이 아직 정해지지 않았습니다. RDF 노드 주소는 지금 요청한 호스트를 따라가서, 바깥에 나갈 주소가 정해지면 UUID 주소의 앞부분이 바뀔 수 있습니다.
  • v1 사용 기관에 보낼 안내 메일은 만들지 않았습니다. 대상은 2.0.0 상세의 '이전 버전을 쓰는 토큰' 표로 찾을 수 있게만 해 두었습니다.

pk를 바깥에 내보내지 말자는 건 여러 번 배운 일인데, 이번에도 JSON에서는 지키고 RDF에서는 놓쳤습니다. 식별자가 응답의 어느 칸으로 나가는지는 시리얼라이저 하나만 봐서는 다 보이지 않았어요. 다음에 식별자를 바꿀 때는 응답 본문에 그 식별자가 나오는 자리를 전부 먼저 뽑아 놓고 시작할 생각입니다.