발행일

Open API를 mock으로 먼저 만들고 OrmDataProvider로 실데이터 전환을 준비한 회고

Open API를 mock으로 먼저 만들고 OrmDataProvider로 실데이터 전환을 준비한 회고

Open API를 만들면서 가장 먼저 한 결정은 "데이터를 어디서 가져오는가"를 코드 한 곳에 가두자 였습니다. 화면과 직렬화 계약(serializer)은 mock으로 빨리 굳히고, 실데이터(ORM) 연결은 백엔드가 안전하게 갈아끼울 수 있도록 분리하고 싶었어요. 그 분리를 StandardDataProvider라는 인터페이스와 OrmDataProvider 골격으로 만든 작업을 정리합니다.


1. 배경 — 화면은 급하고 실데이터는 아직

Open API는 표준데이터(datasets/concepts/schemes/catalogs)를 외부에 노출하는 API입니다. 그런데 화면(명세서·예시·응답 미리보기)은 먼저 보여줘야 했고, 실제 모델 쿼리는 노출 정책·마스킹 같은 민감한 결정이 남아 있어 뒤로 미뤄야 했습니다.

여기서 흔한 함정이 "일단 mock으로 뷰에 박아두고 나중에 ORM으로 바꾸자" 입니다. 그렇게 하면 나중에 뷰 곳곳을 다시 헤집어야 하고, 그 과정에서 응답 형태가 미묘하게 틀어져요.

고려한 네 가지

방법장점포기하는 것판단
① 뷰에 mock을 직접 박고 나중에 교체지금 제일 빠름전환 시 뷰를 전부 헤집어야 함. 응답 형태가 미묘하게 틀어질 자리가 파일 수만큼 생김
② 별도 mock 서버(json-server·MSW 등)프런트가 완전히 독립운영 대상이 하나 늘고, 응답 계약이 코드 밖에 살아 실제 구현과 서서히 어긋남
③ serializer는 그대로 두고 쿼리셋만 분기가벼움마스킹·노출 필터가 뷰에 남음. 가장 민감한 로직이 가장 흩어진 자리에 놓임
④ provider 인터페이스로 출처를 캡슐화전환이 한 줄. 노출 로직이 한곳인터페이스 계약을 사람이 지켜야 함✓ 채택

②가 꽤 매력적이었습니다. 프런트 작업이 백엔드와 완전히 분리되니까요. 접은 이유는 응답 계약이 코드 밖에 있으면 아무도 안 지킨다는 것이었습니다. mock 서버의 JSON과 실제 serializer가 각자 진화하다가, 전환하는 날 형태가 다르다는 걸 알게 되는 패턴을 피하고 싶었어요.

③은 마지막까지 고민했습니다. 훨씬 가볍거든요. 그런데 이 API에서 제일 위험한 게 노출 필터와 마스킹인데, 그게 뷰마다 흩어지면 리뷰가 불가능해집니다. 위험한 로직일수록 한곳에 모여야 한다고 봤어요.

그래서 데이터 출처를 뷰에서 떼어내 provider로 추상화했습니다.


2. 설계 — 출처를 인터페이스 뒤로 숨기기

핵심은 list/retrieve 두 메서드만 가진 추상 인터페이스입니다. 뷰는 "누가 데이터를 주는지" 모르고, get_data_provider()가 골라준 provider에게 요청만 합니다.

# 디스패처 — 출처(source)에 따라 provider를 고른다 (실제 코드의 logger.warning 등은 생략한 요약)
def get_data_provider() -> StandardDataProvider:
    source = ...  # OPEN_API_DATA_SOURCE (settings)
    if source not in _PROVIDERS:
        if source == "orm":
            # OrmDataProvider 골격은 있지만 미완 → mock 으로 폴백
            _PROVIDERS[source] = MockDataProvider()
        else:
            _PROVIDERS[source] = MockDataProvider()
    return _PROVIDERS[source]

이렇게 두면 mock → orm 전환이 "구현체 한 줄 교체" 가 됩니다. 뷰·serializer·테스트는 손대지 않아요.

가장 중요한 계약은 응답 dict 형태입니다. MockDataProvidermock_data/*.json을 그대로 흘려보내므로, OrmDataProvider도 같은 키·같은 페이지네이션 형태를 돌려줘야 했어요.

항목계약
목록 응답{ count, next, previous, results }
next/previouspage 번호 (절대 URL 변환은 상위 base 계층이 담당)
상세 응답serializer가 정의한 필드 그대로 (= mock json과 동일)

3. 구현 — OrmDataProvider 골격

골격이라고 부른 이유는 지금 켜지 않을 것이기 때문입니다. 하지만 "켜지 않는 코드"라도 페이지네이션과 자명한 필드 매핑은 mock과 1:1로 맞춰뒀어요. 그래야 나중에 백엔드가 TODO만 채우면 됩니다.

class OrmDataProvider(StandardDataProvider):
    @staticmethod
    def _paginate(queryset, serialize, page, page_size) -> dict:
        page = max(1, int(page))
        page_size = max(1, min(int(page_size), MAX_PAGE_SIZE))
        count = queryset.count()
        start = (page - 1) * page_size
        end = start + page_size
        items = list(queryset[start:end])
        return {
            "count": count,
            "next": page + 1 if end < count else None,      # mock 과 동일 계약
            "previous": page - 1 if start > 0 else None,
            "results": [serialize(obj) for obj in items],
        }

리소스별 쿼리셋은 한 곳(_queryset)에 모았습니다. 직렬화는 자명한 필드만 채우고, 도메인 지식이 필요한 부분은 TODO(backend) 주석으로 남겼어요.

@staticmethod
def _serialize_dataset(obj, *, detail: bool) -> dict:
    data = {
        "id": obj.id, "name": obj.name, "title": obj.title,
        "url": obj.url, "type": obj.type, "state": obj.state,
        # TODO(backend): 이메일 마스킹(mock: "ki***@ag.com") — 노출 전 필수
        "author_email": obj.author_email,
        # TODO(backend): 등록자(creator)는 마스킹("관*자")
        "creator": obj.creator.name if obj.creator_id else None,
        "concepts": [c.name for c in obj.concepts.all()],
    }
    ...

4. 함정 — 미완 활성화 = 비공개 데이터 유출

여기서 가장 신경 쓴 게 fail-closed였습니다. provider가 노출하면 안 될 데이터를 실수로 흘리면, 그건 단순 버그가 아니라 심사중·비공개 데이터가 공개 API로 새는 보안 사고예요.

그래서 두 가지 장치를 박았습니다.

  • 노출 필터를 쿼리셋 단계에서 닫아둠. datasets는 state='ACT' & private=False, 나머지는 public=True & invisible=False. 기본이 "닫힘"이라, 매핑을 빼먹어도 노출되는 일은 없게 했어요.
if resource == "datasets":
    qs = Dataset.objects.filter(state="ACT", private=False)  # 보안: private 별도 적용
    ...
if resource == "concepts":
    qs = Concept.objects.filter(public=True, invisible=False)
  • 활성화를 일부러 막아둠. OPEN_API_DATA_SOURCE='orm'이어도 골격이 미완이면 mock으로 폴백하고 logger.warning을 남깁니다. 백엔드가 TODO(마스킹·복합 필터·트리 parent 등)를 끝내고 검증한 뒤에야 아래 한 줄로 켭니다.
#     _PROVIDERS[source] = OrmDataProvider()

"코드는 있지만 일부러 안 켠다" 는 게 어색해 보일 수 있는데, 데이터 노출 영역에서는 이 보수성이 맞다고 봤어요.


5. 검증

이 작업의 검증 대상은 "동작하는가"보다 "두 provider의 응답이 같은가" 였습니다. 계약이 어긋나면 전환하는 날 화면이 깨지니까요.

항목결과
목록 응답 키mock·orm 모두 { count, next, previous, results }
next/previous 형태양쪽 모두 page 번호 (절대 URL 아님)
페이지 경계첫 페이지 previous=None, 마지막 페이지 next=None
page_size 상한MAX_PAGE_SIZE로 클램프 (과대 요청 방어)
폴백OPEN_API_DATA_SOURCE='orm' 설정 상태에서도 mock 응답 + logger.warning
노출 필터datasets state='ACT' & private=False, 그 외 public=True & invisible=False
비공개 데이터심사중·비공개 레코드가 목록·상세 어디에도 안 나옴

마지막 두 줄이 이 작업의 핵심 검증입니다. 나머지는 틀리면 화면이 깨지지만, 이건 틀려도 화면은 멀쩡하고 데이터만 샙니다.


6. 한계

  • 인터페이스 계약을 사람이 지켜야 합니다. OrmDataProvider가 다른 키를 반환해도 타입 검사에 안 걸려요. 두 provider의 응답 형태를 비교하는 테스트를 붙이는 게 맞습니다. 지금은 눈으로 대조했습니다.
  • TODO(backend)가 켜기 전 필수 조건인지 아닌지 코드만 보고 알 수 없습니다. 마스킹은 안 하면 사고지만 트리 parent는 안 하면 그냥 빈 값이에요. 두 종류가 같은 주석 형식이라, 받는 사람이 구분하려면 이 글이나 핸드오프 노트를 봐야 합니다.
  • provider가 하나 더 늘면 (예: 캐시 레이어) 디스패처의 분기가 커집니다. 지금은 둘뿐이라 문제없지만, 등록 방식으로 바꿔야 할 시점이 올 거예요.

7. 교훈

  • 출처를 인터페이스 뒤로 숨기면 전환이 한 줄이 됩니다. 뷰가 provider를 모를수록 mock↔orm 전환 비용이 0에 수렴해요.
  • mock의 응답 형태가 곧 계약입니다. 페이지네이션·키 이름까지 mock과 똑같이 맞춰두면 나중 구현이 "빈칸 채우기"가 됩니다.
  • 노출 계층은 fail-closed로. 필터의 기본값을 "닫힘"으로 두고, 미완 구현은 아예 활성화하지 않는 안전장치를 둡니다. 빠뜨려서 보이는 건 버그지만, 빠뜨려서 보이는 건 사고예요.
  • 켜지 않을 코드라도 자명한 부분은 채워두면 핸드오프가 쉬워집니다. TODO(backend)로 남은 결정을 명시하면, 받는 사람이 무엇을 검증해야 하는지 바로 압니다.

화면을 빨리 보여줘야 하는 압박과 데이터 노출의 신중함을 동시에 만족시키는 방법은, 결국 둘을 한 인터페이스로 분리해 두는 것 이었습니다. 다음에 외부 노출 API를 또 만든다면 첫 커밋부터 provider부터 그릴 것 같아요.