Architecture위성 변화탐지 플랫폼 아키텍처 · 3/10

테이블 하나 늘 때마다 파일이 뚱뚱해졌다 - db-api 계층을 다시 나눈 이유

FastAPI 튜토리얼 구조 그대로 쓰다 models.py/crud.py에 로직이 계속 쌓이던 문제를 3계층으로 정리하고, 메타데이터 조회 API의 불필요한 연산을 걷어낸 과정.

2026-07-218 min read
#FastAPI#PostgreSQL#PostGIS#리팩터링#쿼리 최적화

문제: 튜토리얼 구조가 실서비스 규모를 못 버틴다

db-api는 처음에 FastAPI 공식 튜토리얼에서 흔히 보는 구조로 시작했다. models.py에 ORM 모델, crud.py에 조회·생성 로직, router.py에 엔드포인트. 테이블이 두세 개일 때는 아무 문제가 없었다.

문제는 테이블과 기능이 늘면서 시작됐다. crud.py 하나에 위성 메타데이터 조회, 필터링, 카탈로그 갱신, 즐겨찾기 로직이 전부 쌓였다. 파일이 커지는 것 자체보다, 한 파일 안에 서로 다른 관심사가 섞이는 게 진짜 문제였다. 조회 로직을 고치려고 열었는데 옆에 갱신 로직이 있고, 그 갱신 로직이 조회 함수를 재사용하고 있어서 - 뭘 하나 바꾸면 영향 범위를 파일 전체에서 다시 훑어야 했다.

시도한 것: 계층을 역할로 다시 나누기

정답은 새롭지 않다. 엔티티와 로직과 HTTP를 분리하는 것.

api/        ← HTTP 라우팅 (요청 파싱, 응답 직렬화)
services/   ← 비즈니스 로직 (서비스 클래스 단위)
models/     ← 엔티티 정의 (ORM 모델, 테이블당 1파일)

models는 테이블 단위로 쪼갰다. 엔티티 하나에 필드가 늘어도 다른 엔티티 파일에는 영향이 없다. services는 서비스 클래스로 로직을 모았다 - 예를 들어 MetadataService는 메타데이터 조회·필터링만 책임지고, 카탈로그 갱신은 CatalogService가 따로 갖는다. api 라우터는 요청을 받아 해당 서비스를 호출하고 응답을 만드는 것 외에 다른 일을 하지 않는다.

이 구조로 옮기고 나서 좋았던 점은, 이후에 추가된 즐겨찾기·페이지네이션 기능이 "어디에 뭘 놓을지 고민할 필요 없이" 그대로 이 패턴을 따라 확장됐다는 것이다. FavoriteService를 새로 만들고 services에 넣고, api에 라우터 몇 개 추가하는 식으로 끝났다.

메타데이터 조회 API에 숨어있던 낭비

계층을 나누면서 각 서비스 코드를 다시 보다가, 메타데이터 조회 API에서 몇 가지 불필요한 연산이 눈에 띄었다.

1. 매번 전체 ORM 엔티티를 만들고 다시 직렬화한다. 클라이언트가 실제로 쓰는 컬럼은 대여섯 개인데, 쿼리는 항상 전체 컬럼을 읽고 ORM 객체로 만든 다음 그중 일부만 골라 응답으로 내보내고 있었다. 나머지는 만들어졌다가 버려지는 작업이다.

# Before - 전체 컬럼을 읽고 엔티티로 만든 뒤 buriedㅡ일부만 사용
results = session.query(SatelliteMetadata).filter(...).all()
return [to_response(r) for r in results]  # 엔티티 전체를 순회하며 변환

# After - 필요한 컬럼만 프로젝션
results = session.query(
    SatelliteMetadata.id,
    SatelliteMetadata.captured_at,
    SatelliteMetadata.geom,
    SatelliteMetadata.prod_type,
).filter(...).all()

2. 이미 SRID 4326으로 저장된 컬럼에 매번 ST_SetSRID를 다시 적용하고 있었다. 컬럼 자체가 이미 4326으로 저장돼 있으니 이 연산은 매 요청마다 같은 결과를 다시 계산하는 것뿐이었다. 저장 시점에 SRID가 보장된다는 걸 확인하고 제거했다.

3. 필터 조건에 화이트리스트가 없었다. 동적 필터를 만드는 코드가 요청 파라미터의 키를 그대로 컬럼명으로 사용하고 있었는데, 모델에 실제로 존재하는 컬럼인지 검증하는 절차가 없었다. 존재하지 않는 필터 키가 오면 예외가 늦게 터지거나 예상 못한 쿼리가 만들어질 수 있는 구조였다. 모델의 실제 컬럼 목록과 대조하는 화이트리스트를 넣어 허용된 필터만 통과시켰다.

ALLOWED_FILTER_FIELDS = {c.name for c in SatelliteMetadata.__table__.columns}

def build_filters(params: dict):
    invalid = set(params) - ALLOWED_FILTER_FIELDS
    if invalid:
        raise ValueError(f"허용되지 않은 필터 필드: {invalid}")
    return [getattr(SatelliteMetadata, k) == v for k, v in params.items()]

4. 페이지네이션이 없었다. 조건에 맞는 행을 전부 한 번에 반환하고 있었다. 데이터가 늘어날수록 응답 크기와 지연이 그대로 늘어나는 구조였다. 페이지·limit 파라미터를 추가하고 기본값을 1000행으로 뒀다.

5. 반복되는 datetime 파싱을 루프 밖으로 뺐다. 행마다 같은 포맷 문자열로 파싱하던 코드를 루프 진입 전에 포맷터 하나를 만들어두고 재사용하도록 바꿨다. 그리고 응답 생성 시간을 조회(fetch)와 직렬화(serialize) 두 구간으로 나눠 로깅해서, 이후에 다시 느려지면 어느 구간이 원인인지 바로 보이게 했다.

결과

정량적인 before/after 수치를 아직 정식으로 재지는 못했다 - 그래서 여기 숫자를 넣지 않는다. 대신 확실해진 건 응답 크기와 속도를 통제할 수 있는 구조가 됐다는 것이다. 컬럼 프로젝션과 페이지네이션 덕분에 이제 데이터가 늘어도 응답 크기가 무한정 커지지 않고, 화이트리스트 덕분에 필터 파라미터가 늘어도 쿼리가 예상 밖으로 새지 않는다.

계층 분리 쪽은 효과가 더 분명하다. 즐겨찾기 기능을 추가할 때 "이 로직을 어디에 놓을지"를 고민한 시간이 거의 없었다 - 이미 정해진 자리(services)가 있었기 때문이다. 다음에 측정할 것은 실제 쿼리 응답 시간의 before/after 수치다. 지금 구조는 측정 지점(fetch/serialize 로그)까지는 만들어뒀으니, 다음 단계는 이 로그를 근거로 실제 개선폭을 숫자로 남기는 것이다.