발행일

목업으로 먼저 짠 Open API의 교체 장치는 한 번도 쓰이지 않았다 — 계약째 버리고 공개 화면의 노출 기준에 붙이기

목업으로 먼저 짠 Open API의 교체 장치는 한 번도 쓰이지 않았다

이 시스템에는 외부 연계 시스템이 표준데이터를 토큰으로 조회하는 Open API가 있습니다. 토큰 발급·호출 한도·접근 로그·명세 표출 관리 틀은 같은 회사의 다른 시스템에 있던 Open API를 옮겨 온 것이고, 이 글은 그 위에 무엇을, 어떤 기준으로 내보낼지를 정한 이야기입니다(e15c20cb·acae1c49, 뒤처리 aae0754e·994667b3·c1ae24ab).

6월의 설계 — 목업 먼저, 교체 지점 하나

6월 5일에 쓴 1차 설계서의 핵심 원칙은 "목업 먼저"였습니다. 응답을 DB가 아니라 JSON 목업 파일에서 읽고, 공개 뷰는 모델을 직접 부르지 않고 공급자(provider)만 거치게 했어요.

[공개 뷰] → get_data_provider() → ┌ MockDataProvider (지금: mock_data/*.json)
                                   └ OrmDataProvider  (추후: 모델 쿼리)

설정값 하나(OPEN_API_DATA_SOURCE)를 "mock"에서 "orm"으로 바꾸고 OrmDataProvider만 채우면 주소·응답 계약·문서·화면은 바뀌지 않는다는 게 이 구조의 약속이었습니다. 6월에 provider 추상화 글에서 "구현체 한 줄 교체"라고 적은 그 장치예요. 결론부터 말하면 그 한 줄은 끝내 쓰이지 않았습니다. 공개 리소스는 그때 모델 기준으로 데이터셋·개념·개념체계·카탈로그 넷이었고요.

덕분에 명세서 화면과 예시 응답은 실제 데이터가 붙기 전에 먼저 만들 수 있었습니다. 그 몫은 분명히 있었어요.

9월 — 바뀐 건 데이터 출처가 아니라 계약이었다

9월에 실제 데이터를 붙이려고 보니 OrmDataProvider를 쓸 자리가 없었습니다. 그사이 표준데이터 모델이 고정 컬럼에서 인스턴스 기반으로 옮겨 갔기 때문입니다(표준데이터를 고정 컬럼에서 인스턴스 기반 모델로 옮긴 글). 공개 검색 화면이 다루는 단위는 이제 메시지·데이터 프레임·데이터 엘리먼트 셋이고, 카탈로그·개념체계는 그 위의 필터일 뿐이었어요. 데이터셋·개념·개념체계·카탈로그라는 리소스 이름부터가 더는 맞지 않았습니다.

교체 장치가 지키려던 건 "계약은 그대로, 출처만 바뀐다"는 가정이었는데, 실제로 바뀐 건 계약 쪽이었습니다.

  • 리소스: 데이터셋·개념·개념체계·카탈로그 → 메시지·데이터 프레임·데이터 엘리먼트, 그리고 OID
  • 상세 응답: JSON → RDF(JSON-LD 기본, Turtle, RDF/XML을 Accept나 ?format=으로 선택)

그래서 공급자에 구현체를 하나 더 붙이지 않고, 목업 네 종의 데이터·뷰·공급자 인터페이스·설정값·1차 설계서를 한 커밋에서 지웠습니다(acae1c49, 새 뷰·테스트를 더한 2,284줄에 지운 줄이 2,747줄). 남은 인스턴스 데이터 형태에 맞지 않는 인터페이스를 살려 두면, 다음 사람은 "여기에 OrmDataProvider를 채우면 된다"고 읽게 됩니다.

노출 규칙이 주석에만 있었다

지우면서 더 걸렸던 건 따로 있었습니다. 공급자 인터페이스의 설명에 노출 정책이 이렇게 적혀 있었어요.

외부노출 정책 — datasets 는 state==ACT & private==False, concepts/schemes/catalogs 는 public==True & invisible==False 만 노출 — 은 실제 데이터를 다루는 OrmDataProvider 가 책임진다. MockDataProvider 는 JSON 을 이미 노출 가능 항목으로만 큐레이션해 두므로 이 단계에서 필터링하지 않는다.

무엇을 밖으로 내보내도 되는지가 코드가 아니라 주석에 있었다는 뜻입니다. 목업은 손으로 고른 항목만 담고 있으니 테스트는 늘 통과했습니다. OrmDataProvider 골격에는 이 규칙을 모델 필드로 옮긴 필터 쿼리가 있었지만, 미완이라 연결하지 않고 목업으로 폴백하게 둬서 한 번도 실행된 적이 없었어요. 그 골격을 켰다면 공개 화면과는 따로 적은 두 번째 규칙이 나갔을 거고, 둘이 어긋나는 순간 화면에서는 안 보이는 항목이 API로는 나갑니다.

그래서 이번에는 규칙을 새로 쓰지 않았습니다. 공개 검색 화면이 쓰는 모집단 함수를 API가 그대로 부르게 했어요.

def get_queryset(self):
    return MetaClassInstanceManager.get_std_data_queryset(public_only=True).filter(
        MetaClassInstanceManager.metaclass_key_condition(self.type_entry["curie"])
    )

get_std_data_queryset은 홈 건수의 모집단이고, 공개 목록·통합검색과 같은 범위를 지키도록 이미 묶여 있던 함수입니다. 공개 여부의 기준이 바뀌면 화면과 API가 함께 바뀌고, 한쪽만 새는 일이 생기지 않아요. 개발 문서에도 이 문장을 원칙으로 남겼습니다. "공개 화면과 API가 같은 기준을 공유해야 한쪽만 새는 일이 없다."

같은 원칙을 응답 내용에도 적용했습니다. 상세 응답의 첫 구현은 그 항목의 값과 이웃 이름만 담았는데, 그러면 상세 화면에 있는 필드 표·데이터 표현·값 집합·활용 메시지가 빠집니다. 그 조각을 따로 조회하는 API는 없으니 받는 쪽이 재조립할 방법이 없었어요. 같은 날(9월 14일) 기본 응답 = 공개 상세 화면에 보이는 내용으로 바꿨고(커밋은 다음 날 한 번에 들어갔습니다), 직렬화도 상세 화면의 RDF 다운로드와 같은 함수(RDFFactory.serialize_instance)를 씁니다.

depth=full이 공개 데이터 전체를 끌고 오지 않게

?depth=full을 주면 필드를 따라 하위 데이터 프레임·데이터 엘리먼트의 내용까지 싣습니다. 여기서 따라갈 관계를 고르는 게 중요했어요. RDF 그래프에는 표준·상태 같은 공용 어휘로 가는 관계가 있는데, 이것까지 따라가면 그 허브를 타고 공개 인스턴스 거의 전부가 딸려 옵니다.

그래서 구조 관계(messageField·dataFrameField·references 등)만 나가는 방향으로 따라가고, 그 범위 안의 자원마다 딸린 조각(필드 요구 수준, 데이터 표현, 값 집합, 데이터 유형)만 값까지 싣습니다. 딸린 조각에서 이어지는 관계는 바깥으로 나가는 링크가 없거나 되돌아오는 것뿐이라 더 번지지 않아요.

개발 DB에서 잰 크기는 이렇습니다.

요청트리플크기
메시지 한 건, 기본약 37021KB
메시지 한 건, depth=full약 3,500206KB (0.3초)
가장 많이 쓰이는 데이터 엘리먼트, 기본약 83046KB

데이터 엘리먼트가 기본 응답에서도 큰 건 "이 항목을 쓰는 상위 프레임·메시지"까지 싣기 때문입니다. 위로 올라가는 사슬은 중심 항목에만 적용하고, 그 위 항목은 이름과 구조 링크만 담아서 여기서도 번지지 않게 막았습니다.

오류 처리에서 하나 더 걸렸습니다. 상세 뷰의 렌더러를 RDF로 바꾸면 404·400 응답의 dict 본문도 RDF 렌더러가 그리려다 500이 납니다. finalize_response에서 본문이 문자열이 아니면 JSON 렌더러로 바꿔 끼웠고, "없는 항목·다른 유형·비공개는 JSON 404"를 테스트로 묶었습니다.

OID — 같은 기준이지만 기존 함수는 못 썼다

6월 설계에서는 OID를 공개 리소스에서 아예 뺐었는데, 이번에는 넣었습니다. OID는 인스턴스가 아니라 트리라서 따로 뺐고, 저장된 트리플이 없으니 RDF가 아니라 JSON으로 냅니다. 노출 기준은 공개 OID 검색 트리와 같아요. 등록 상태이면서 조상도 모두 등록인 노드만 냅니다.

여기서는 화면 쪽 함수를 그대로 부르지 못했습니다. 점 표기 맵을 만드는 기존 함수가 점 표기를 키로 쓰는데, 번호가 겹친 형제가 있으면 하나를 버리고, 버려진 쪽 아래 노드의 상위 조회가 500이 납니다. 겹침을 막는 DB 제약이 없어서 실제로 생길 수 있는 경우예요(dev의 OID 번호를 운영과 같게 심은 글에서 다룬 번호 문제와 같은 뿌리입니다). 그래서 path 순으로 한 번 훑으며 pk를 키로 쓰는 맵을 따로 만들고, "번호가 겹친 형제의 하위도 상세가 열린다"를 테스트로 남겼습니다.

개인정보는 화면과 기준을 일부러 다르게 잡았습니다. 화면은 등록기관 전화번호와 등록자를 보는 사람의 권한에 따라 가리는데, 이 판정을 API에 그대로 태우면 관리자 계정으로 발급한 토큰에는 원본이 나갑니다. API는 권한과 상관없이 둘 다 싣지 않아요.

일주일 뒤 — OID를 명세에서 뺐다

9월 22일에 OID API를 명세에서 뺐습니다. 외부 검수 항목이 명세 태그를 메시지·데이터 프레임·데이터 엘리먼트·토큰 관리 넷으로 잡고 있었는데, 일주일 전에 제가 OID를 다섯째 태그로 넣어 둔 상태였습니다.

처음에는 명세서 화면의 제외 목록에 OID 경로를 넣었습니다(aae0754e). 그런데 명세 태그는 배포 때 스키마를 읽어 DB에 다시 만드는 구조라, 화면에서 숨기거나 DB의 행만 지우면 다음 동기화 때 되살아납니다. 같은 날 저녁 원천인 스키마에서 빼는 쪽으로 바꿨습니다(c1ae24ab, 두 뷰의 @extend_schema(exclude=True)). 화면 제외 목록에 넣었던 줄은 거기서 다시 걷었어요.

한계

  • OID API는 문서에 없는데 호출은 된다. 커밋에는 "엔드포인트 호출은 유지"라고만 적었고 왜 남기는지는 쓰지 않았습니다. 연계 흐름 e2e(메시지의 OID로 OID 상세를 찾고 다시 메시지로)는 계속 이 주소를 부르지만, 명세에 없는 공개 주소는 받는 쪽에서 보면 존재하지 않는 계약입니다. 남길지 내릴지는 아직 정하지 않았습니다.
  • OID 요청마다 전량을 훑는다. 노출 맵을 만들려고 요청마다 OID 전체(개발 DB 1,514건)를 한 번 읽습니다. 지금 규모에선 문제가 없지만 캐시 없이 늘어나는 비용입니다.
  • 목업 단계를 다시 한다면. 화면을 먼저 만들 수 있었던 건 목업 덕이었습니다. 다만 노출 규칙은 목업 단계에서도 실제 함수를 거치게 하고, "계약은 그대로"라는 가정은 모델 전환 같은 변경이 예정돼 있으면 처음부터 의심했어야 했어요.