Skip to content

Latest commit

 

History

History
286 lines (231 loc) · 14.3 KB

architecture.md

File metadata and controls

286 lines (231 loc) · 14.3 KB

구현 반영됨(ai#5·#6, app/). 이 문서는 계약 명세이며 구현이 이를 따른다. 리포트: implements/2026-07-23-fastapi-implementation.md. 공용 계약은 Team-PinLog/docs의 static/05_AI_설계.md를 따릅니다.

Architecture

근거 계약: static/05_AI_설계.md §3.2 FastAPI 책임, §3.3 접근 금지 경계, §12.6 Migration 소유권, §13.3 API 보안과 접근 범위

1. 설계 기준

FastAPI 서버는 다음 세 가지 경계 위에서 설계합니다.

  1. 스키마 경계 — ai 스키마만 사용합니다. core.*는 조회도 하지 않습니다.
  2. 책임 경계 — 인증 판단, Core 상태 변경, Feed 계산을 하지 않습니다.
  3. 소유권 경계 — 스키마 요구사항과 Query는 이 레포가 정의하지만, Migration 실행 주체는 back입니다.

이 경계 때문에 FastAPI에는 "도메인 트랜잭션"이 없습니다. 있는 것은 외부 모델 호출과 ai 스키마에 대한 짧은 정합성 트랜잭션뿐이며, 계층 구조도 이 특성에 맞춥니다.

2. 시스템 맥락

FastAPI 서버가 전체 AI 시스템에서 차지하는 위치와 경계입니다. 공용 시스템도는 static/05_AI_설계.md §3에 있고, 여기서는 FastAPI 관점으로 봅니다.

flowchart LR
    Client(["Client"])

    subgraph Spring["Spring Backend"]
        direction TB
        SCORE["Core 도메인 · 인증"]
        SAI["AI State 생성 · 재스캔 · 응답 조립"]
    end

    subgraph Fast["FastAPI AI Server"]
        direction TB
        API["/internal/v1/*"]
        SVC["처리 · 검색 service"]
    end

    subgraph Store["데이터 (동일 PostgreSQL 인스턴스)"]
        CORE[("core 스키마")]
        AISC[("ai 스키마 · pgvector")]
    end
    RD[("Redis · Feed")]
    EXT["Embedding / LLM API"]

    Client -->|"외부 API /api/core/v1"| Spring
    Spring -->|"내부 호출 /internal/v1"| Fast
    Spring --> CORE
    Spring --> RD
    Spring -. "읽기 조인 (공개 Keyword)" .-> AISC
    Fast --> AISC
    Fast --> EXT
    Fast -. "✗ 접근 금지" .-> CORE

    linkStyle 7 stroke:#c0392b,stroke-width:2px
Loading
  • Client는 FastAPI를 직접 호출하지 않습니다. 모든 외부 요청은 Spring을 거칩니다.
  • FastAPI는 ai 스키마와 외부 모델 API만 접근합니다. core.*는 조회도 하지 않습니다(빨간 경계).
  • Spring은 응답 조립을 위해 ai 스키마를 읽기 조인하지만, 반대 방향(FastAPI→core)은 없습니다.

두 개의 진입 경로가 있고 처리 방식이 다릅니다.

flowchart TB
    subgraph P["POST /internal/v1/context/process — 비동기"]
        P1["202 즉시 반환"] --> P2["백그라운드 파이프라인"]
        P2 --> P3["Embedding → Preset 후보 → LLM 판정 → 저장"]
    end
    subgraph S["POST /internal/v1/search — 동기"]
        S1["질의 Embedding"] --> S2["본인 Context exact cosine"]
        S2 --> S3["record_id 집계 → 반환"]
    end
Loading

3. 모듈 구조

app/
├── main.py                     # FastAPI 인스턴스, lifespan, 라우터 등록
├── bootstrap/
│   └── load_presets.py         # Preset 시드 CLI (수동 실행 — DB 적재)
├── api/
│   └── internal/v1/
│       ├── context.py          # POST /internal/v1/context/process
│       └── search.py           # POST /internal/v1/search
├── schema/                     # 요청·응답 Pydantic 모델 (내부 API 계약 표현)
│   ├── context.py
│   ├── search.py
│   └── llm.py                  # LLM 구조화 출력 스키마
├── service/
│   ├── context_processing.py   # 처리 파이프라인 오케스트레이션
│   ├── embedding_service.py    # Embedding 생성·재사용 판단
│   ├── keyword_service.py      # 후보 검색 + LLM 판정
│   └── search_service.py       # 개인 검색
├── repository/
│   ├── ai_state_repo.py        # ai.context_ai_state
│   ├── context_embedding_repo.py
│   ├── context_keyword_repo.py # ai.context_keyword, ai.context_keyword_analysis
│   └── keyword_preset_repo.py  # ai.keyword_preset
├── cache/
│   └── preset_cache.py         # Keyword Preset 메모리 Cache
├── client/
│   ├── embedding_client.py     # Embedding API
│   └── llm_client.py           # LLM API
└── core/
    ├── config.py               # 단일 설정 진입점 (Embedding Profile 포함)
    ├── db.py                   # asyncpg 풀·커넥션 획득(acquire/transaction), search_path
    ├── errors.py               # 오류 분류 타입
    ├── logging.py
    └── security.py             # 내부 공유 시크릿 미들웨어(/internal/*)

계층 간 호출은 한 방향입니다. 화살표 방향으로만 의존하며, 역방향 의존이나 계층 건너뛰기를 두지 않습니다.

flowchart TB
    API["api / router<br/>요청 검증 · 202 반환"]
    SVC["service<br/>파이프라인 · 상태 전이 결정 · TX 경계"]
    REPO["repository<br/>ai 스키마 SQL · 영향 행 수 반환"]
    CACHE["cache<br/>Preset 스냅샷"]
    CLIENT["client<br/>Embedding / LLM 호출 · 오류 분류"]
    DB[("ai 스키마")]
    EXT["Embedding / LLM API"]

    API --> SVC
    SVC --> REPO
    SVC --> CACHE
    SVC --> CLIENT
    REPO --> DB
    CLIENT --> EXT
    CACHE -. "startup 1회 적재" .-> REPO

    classDef io fill:#eef,stroke:#557
    class DB,EXT io
Loading
  • service만 여러 계층을 오케스트레이션합니다. repository·client·cache는 서로를 직접 부르지 않습니다.
  • repository는 상태 전이를 판단하지 않고 영향 행 수만 반환합니다. 중단 여부는 service가 정합니다.
  • client는 DB를, repository는 외부 API를 모릅니다. 두 축이 만나는 지점은 오직 service입니다.

4. 계층 규칙

계층 하는 일 하지 않는 일
api (router) 요청 검증, 서비스 호출, 상태코드 결정 SQL 작성, 외부 모델 호출, 상태 전이 판단
service 파이프라인 순서, 상태 전이 결정, 트랜잭션 경계 지정 SQL 문자열 직접 작성, HTTP 세부 처리
repository ai 스키마 SQL 실행, 영향 행 수 반환 상태 전이 여부 판단, 재시도 정책
client 외부 API 호출, 타임아웃, 오류 분류 DB 접근, 상태 전이
cache Preset 스냅샷 보관 DB 쓰기

핵심 규칙:

  • 조건부 UPDATE의 "영향 행 수"는 repository가 그대로 반환하고, 중단 여부는 service가 판단합니다. repository가 예외를 던져 흐름을 결정하지 않습니다. 상세는 state-machine.md.
  • router는 상태를 모릅니다. POST /internal/v1/context/process는 요청 형식만 검증한 뒤 202 Accepted를 반환하고, 실제 처리는 service가 백그라운드에서 수행합니다.
  • client는 DB를 모릅니다. 외부 호출 실패는 분류된 오류로 service까지 올라오고, 상태 반영 여부는 service가 결정합니다. 분류 기준은 failure-recovery.md.

5. Preset Cache 위치

Keyword Preset은 25~30개 규모의 준정적 데이터이고 Preset 변경은 배포 작업으로 처리하므로, 요청마다 DB에서 읽지 않고 프로세스 메모리 Cache에 둡니다.

  • 위치: app/cache/preset_cache.py. service가 소유하고 keyword_service만 소비합니다.
  • 적재 시점: main.py의 lifespan startup에서 keyword_preset_repo를 통해 1회 적재.
  • 적재 범위: is_active = true이고 현재 Embedding Profile과 일치하는 행.
  • 보관 내용: id, code, display_name, category, description, examples, visibility, version, embedding.
  • 무효화: 프로세스 재시작으로만 재적재됩니다. Preset 변경이 배포로만 이루어지므로 TTL 재적재나 별도 무효화 API를 두지 않습니다.
  • 다중 워커: 워커별로 독립 Cache를 가집니다. Preset은 읽기 전용이므로 워커 간 동기화가 필요 없습니다.

Cache는 후보 판정에 필요한 메타데이터 제공이 목적입니다. 후보 TOP-K 벡터 검색을 메모리에서 할지 DB에서 할지는 keyword-preset.md에서 정의합니다.

6. DB 세션 경계

6.1 원칙

FastAPI의 트랜잭션은 짧고 여러 개입니다. 외부 모델 호출을 트랜잭션 안에 넣지 않습니다.

[TX1] 사전 검사 조회                    (읽기, 즉시 커밋)
[TX2] PROCESSING 조건부 UPDATE           (쓰기, 즉시 커밋)
      ── 트랜잭션 밖 ── Embedding API 호출
      ── 트랜잭션 밖 ── LLM API 호출
[TX3] FOR UPDATE 재검사 + 결과 저장 + COMPLETED 전이 (하나의 트랜잭션)
sequenceDiagram
    autonumber
    participant SVC as service
    participant DB as ai 스키마
    participant EXT as Embedding / LLM

    Note over SVC,DB: TX1 — 사전 검사 (읽기)
    SVC->>DB: 상태 조회
    DB-->>SVC: 현재 status

    Note over SVC,DB: TX2 — PROCESSING 조건부 UPDATE
    SVC->>DB: UPDATE ... WHERE status 허용
    DB-->>SVC: 영향 행 수
    alt 영향 행 0
        SVC-->>SVC: 즉시 중단 (모델 미호출)
    end

    Note over SVC,EXT: 트랜잭션 밖 — 잠금 없음
    SVC->>EXT: Embedding / LLM 호출
    EXT-->>SVC: 결과

    Note over SVC,DB: TX3 — 저장 직전 잠금·재검사·쓰기
    SVC->>DB: SELECT ... FOR UPDATE
    DB-->>SVC: status
    alt status ≠ PROCESSING (삭제·수정 경합)
        SVC-->>SVC: 결과 폐기
    else
        SVC->>DB: 결과 저장 + COMPLETED
    end
Loading

모델 호출은 수 초가 걸리므로 그 구간에 행 잠금을 잡고 있으면 Spring의 삭제·수정 트랜잭션과 재스캔이 함께 막힙니다. 따라서 잠금은 저장 직전 TX3에서만 잡고, 그 안에서는 SQL만 수행합니다.

TX1이 통과했더라도 TX3에서 다시 검사해야 하는 이유는 deletion-race-control.md를 참조합니다.

6.2 구현 규칙

  • 커넥션 풀은 app/core/db.py의 Database가 소유하고, service가 async with db.acquire() (읽기) 또는 async with db.transaction()(쓰기 TX)로 필요한 구간에만 엽니다. 요청 전체를 하나의 세션으로 감싸지 않습니다.
  • 검색·처리 모두 서비스가 자신의 세션을 직접 엽니다. 검색 라우터는 Depends로 서비스만 주입하고(search.py), SearchService가 db.acquire()로 단일 읽기 세션을 엽니다. 요청 스코프 DI 세션(get_session)은 두지 않습니다.
  • 처리 파이프라인은 백그라운드에서 실행되므로 요청 스코프 세션을 사용할 수 없습니다. 서비스가 자신의 세션을 직접 열고 닫습니다.
  • TX3은 SELECT ... FOR UPDATE → 검증 → 쓰기 → 커밋을 한 세션에서 수행합니다. 중간에 어떤 네트워크 호출도 넣지 않습니다.
  • Embedding 단계 TX3과 Keyword 단계 TX3은 분리합니다. 두 status가 독립 전이하므로 Embedding COMPLETED가 Keyword 실패로 롤백되면 안 됩니다.

6.3 접속 권한과 스키마

  • DB 롤은 ai 스키마에 대한 권한만 가집니다. core.* 권한을 부여하지 않습니다.
  • 커넥션의 search_path를 ai, public으로 고정합니다. core를 경로에서 제외해 실수로 다른 스키마를 참조하지 못하게 합니다. 반면 public은 포함합니다. pgvector VECTOR 타입 해석과 register_vector가 의존하는 확장이 public에 있어, 빼면 멀티 커넥션 환경에서 타입 해석이 실패하기 때문입니다(T21).
  • 애플리케이션 기동 시 DDL을 실행하지 않습니다. 테이블이 없으면 기동 실패로 처리하고, 스키마 생성은 back의 migration을 기다립니다.
  • ai.context_embedding.is_deleted는 읽기만 합니다. FastAPI의 UPSERT는 이 컬럼을 갱신 대상에 포함하지 않습니다.

7. 사용하는 테이블

테이블 FastAPI 접근
ai.keyword_preset 읽기
ai.context_ai_state 읽기 / 조건부 UPDATE (status, updated_at)
ai.context_embedding 읽기 / UPSERT (is_deleted 제외)
ai.context_keyword 읽기 / DELETE + INSERT
ai.context_keyword_analysis UPSERT
core.* 접근하지 않음

retry_count는 Spring이 관리합니다. FastAPI는 이 컬럼을 증가시키지 않습니다.

8. 노출 경계

  • /internal/v1/* 만 노출하며 내부 네트워크에서만 접근 가능합니다.
  • 서비스 간 인증(공유 시크릿 헤더 등)을 미들웨어에서 검증합니다. User 인증은 판단하지 않습니다.
  • userId는 요청 본문으로 전달받아 필터로만 사용합니다. 소유권 판단은 Spring이 합니다.

9. 확장 규칙 (챗봇·GraphRAG 등 신규 기능)

AI 파트에 새 기능(챗봇, GraphRAG 등)이 합류할 것을 전제로, 기존 구조를 지키기 위한 규칙입니다.

  • 계층을 유지합니다. 새 기능도 api → service → {repository, cache, client}를 따릅니다. 기능이 커지면 각 계층 안에서 기능별 파일 또는 서브패키지(예: service/chat/)로 나누고, 계층 구조 자체를 갈아엎지 않습니다. 지금은 기능이 하나라 평면 구조이며, 분리 시점은 실제 합류 때 판단합니다. 기능이 오기 전에 미리 나누는 것은 과잉 설계입니다.
  • 기존 내부 엔드포인트 계약을 보호합니다. /internal/v1/{context,search}의 요청·응답 스키마는 Spring 연동 계약입니다. 새 기능이 여기에 임의 필드를 덧붙이지 않습니다. 계약 변경이 필요하면 static/05_AI_설계.md 개정을 거칩니다(예: /search 응답 contextId 추가는 이 절차를 밟았습니다). 새 기능은 새 엔드포인트로 추가하는 것이 기본입니다.
  • 무거운 ML 의존성은 분리를 검토합니다. GraphRAG 계열이 torch·transformers 등을 들고 오면 단일 이미지가 수 GB가 됩니다. 단일 이미지 / optional extras / 별도 서비스 중 무엇으로 갈지는 스택이 확정되는 합류 시점에 판단합니다. 현재 FastAPI 서버는 경량 의존성을 유지합니다.