본문 바로가기
C.W.K.
Lesson 01 of 06 · published

같은 라우트, 다른 얼굴 둘

~13 min · api, clients, fastapi, swiftui, react

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

엔진 하나, 얼굴 둘

Sizzle 엔진의 첫 줄들이 대놓고 말해. 웹 클라이언트랑 아이폰 앱은 같은 라우트를 쓰고, cwkPippa에는 엔진만 루프백으로 닿는다고. 이 한 문장이 트랙 전체의 모양을 정해. 엔진은 FastAPI 프로세스 하나고, 저널, 미러, 사진, 읽기 워커를 다 쥐고 있어. 웹 클라이언트는 엔진이 자기 포트에서 직접 내주는 React 19 페이지야. 아이폰·아이패드 앱은 유니버설 SwiftUI 타깃 하나고. 두 클라이언트 다 두뇌 자격 증명이 없고, 두뇌가 답할 일을 cwkPippa한테 직접 묻지도 않아. 답에 피파가 끼어 있다면, 그건 엔진이 나한테 물어본 거야. 그래도 엔진 첫 줄은 코드보다 넓게 말해. 폰은 cwkPippa를 딱 한 번 직접 불러. 엔진 호스트에 있는 cwkPippa의 열린 호스트 프로필 문서를 읽는 건데, 가족 서비스가 어느 Mac에 있는지 알려주는 문서야(레슨 6). 그 호출엔 두뇌도 대화도 없어.

그래서 이 제품의 진짜 인터페이스는 라우트 목록이야. 머릿속에 다 들어갈 만큼 짧아.

영역라우트실어 나르는 것
상태GET /api/health앱 이름과 제품 버전
접시GET, POST /api/encounters, GET, PATCH /api/encounters/{id}, POST …/reread, …/corrections, …/links목록(한 페이지 최대 500개, 오프셋 있음), 캡처, 읽기가 붙은 접시 하나, 아빠의 수정
사진GET /api/photos/{id}/{display,thumb,original}, POST /api/staged-uploads한번 저장되면 안 바뀌는 사진 바이트, 요청 하나에 사진 한 장씩 올리는 스테이징
트리GET /api/tree, GET, PATCH /api/classes/{id}, POST …/adopt, …/rename, …/variants원리, 요리, 변형
스레드GET /api/threads/{key}, POST …/ask, POST /api/turns/{turn}/actions/{index}/apply아빠의 대화, 그리고 피파 제안 하나를 적용하는 버튼
피파 미디어GET /api/pippa/avatar/{emotion}, GET /api/pippa/uploads/{id}, …/thumb셰프 아바타, 그리고 피파가 답에 인용하는 이미지. 엔진이 루프백으로 가져와서 Sizzle 자기 출처에서 내줘
두뇌GET /api/brain-control/slots, …/status, PUT …/slots/{slot}Sizzle 기능마다 어느 두뇌가 도는지

나머지는 전부 웹 셸이야. 킷의 정적 파일 마운트는 모든 API 라우트 뒤에 맨 마지막으로 붙어. 그래서 API 경로를 잘못 치면 404로 끝나고, 호출한 쪽이 JSON으로 못 읽을 HTML 페이지가 돌아오는 일은 없어.

타입 있는 클라이언트 둘, 와이어 하나

웹 클라이언트는 타입을 api.ts에 둬. Encounter, EncounterDetail, ClassRow, Turn, 그리고 피파가 제안할 수 있는 액션 여섯 개를 묶은 판별 유니온까지. 폰의 Models.swift는 첫머리에서 자기 와이어 모양이 그 파일을 옮겨 적은 거라고 밝혀. 생성된 SDK도 없고 공유 스키마 패키지도 없어. 두 파일은 손으로 맞춰 가. Swift 파일 첫머리 주석은 읽기 필드를 너그럽게 디코딩하니까 이상한 필드 하나로 접시 전체를 잃진 않는다고 해. 그건 코드보다 큰소리야. 읽기 필드는 전부 합성된 Codable 구조체의 옵셔널이라서, 빠졌거나 null인 필드는 nil로 풀려. 그런데 타입이 틀린 필드, 그러니까 문자열 자리에 숫자가 오면 그대로 오류가 나고 접시 상세 전체가 디코딩에 실패해. 모델의 이상한 JSON을 폰에서 막아 주는 건 사실 엔진이야. 엔진은 읽기를 기록하기 전에 pydantic 루브릭으로 하나하나 검증하니까, 클라이언트가 읽을 땐 타입이 이미 정해져 있어.

거절을 읽는 필드도 둘이 같아. FastAPI는 이유를 detail 필드에 넣고, 그건 문자열이거나 검증 항목 목록이야. 웹의 call 헬퍼는 OK가 아닌 답을 전부 그 detail을 담은 ApiError로 바꿔. 본문을 못 읽으면 상태 코드만 남기고. 폰의 failure 함수도 같은 필드를 읽어. 그래서 kind must be one of out, home, market, other(종류는 넷 중 하나여야 한다는 뜻) 같은 문자열 거절은 두 화면에서 똑같이 읽혀. 검증 목록은 달라. 웹은 목록 전체를 JSON.stringify로 직렬화해서, 사용자는 위치 정보까지 붙은 날것의 항목을 그대로 봐. 폰은 항목마다 msg만 골라서 세미콜론으로 이어 붙이고. 이 차이가 잘 안 보이는 건 Sizzle 자기 거절이 다 문자열이라서야. 목록은 Sizzle 코드가 돌기도 전에 FastAPI가 요청을 튕길 때만 나와.

무게가 다르면 통로도 달라

폰은 모든 요청을 세션 하나로 밀어 넣지 않아. EngineClient는 전송 경로 세 개를 정책 세 개와 함께 들고 있어. JSON은 요청 타임아웃 15초, 리소스당 30초, 예산 8 MB야. 이미지는 30초와 60초, 24 MB. 업로드는 바이트가 60초 동안 안 움직이면 끊고, 리소스 전체는 300초야. 이 두 시간은 바로 위 주석대로 스테이징 사진 한 장이나 접시의 작은 폼에 맞춘 거고. 바이트 예산은 셋 다 얘기가 달라. 킷의 HTTPSessionPolicy에서 예산은 답이 실어 올 수 있는 바이트의 상한이야. 그러니 업로드의 1 MB는 엔진이 돌려주는 작은 JSON 답의 자리지, 올라가는 사진의 한도가 아니야. 전송 경로를 주입받으니까, 단위 테스트는 같은 클라이언트에 각본대로 답하는 전송을 끼워서 돌려. 엔진엔 한 번도 안 닿아. UI 타깃은 얘기가 달라. 거기 테스트는 진짜 앱을 띄우고, 진짜 앱은 기본 전송 경로로 접시 목록을 읽고 기기 사본 동기화를 시작해. 상대는 앱에 설정된 엔진이야. 사진 지우기 테스트는 그 통신에 대해 아무것도 확인하지 않아서, 엔진이 답하든 말든 통과해. 일부러 엔진을 겨눠서 재는 건 업로드 벤치마크 하나뿐이고, 그것도 켜야 돌아(트랙 8, 레슨 4). 웹은 JSON엔 fetch를, 스테이징엔 XMLHttpRequest를 써. 요청 본문이 얼마나 나갔는지 fetch로는 알 수가 없거든(레슨 4).

폰에 없는 것

아키텍처 문서는 폰이 같은 라우트로 웹 화면을 빠짐없이 옮겼다고 해. 대체로 맞고, 어디서 멈추는지는 코드가 정확히 보여줘.

  • 웹 클래스 페이지엔 Make my version(내 버전 만들기) 폼이 있어서 /api/classes/{id}/variants로 보내. 폰 클라이언트엔 변형 호출이 아예 없어. 폰에서 변형은 스레드에서 적용하는 피파의 variant 액션으로만 생겨.
  • 접시를 이미 있는 클래스에 손으로 잇는 건 두 클라이언트 다 안 해. 웹은 api.link를 정의만 하고 부르지 않고, 폰엔 그런 호출이 없어. 그 연결은 피파의 link 액션으로 만들어져. 웹에 손으로 잇는 자리가 딱 하나 있긴 해. 변형 폼 안이야. 폼은 그 클래스의 집밥 접시를 보여주고, 거기서 고른 접시는 엔진이 새로 만든 변형에 이어 줘.
  • 웹은 평가 칸을 변형에서만, 파라미터를 요리와 변형에서만 보여줘. 폰은 둘 다 모든 클래스에서 보여주고.
  • 종류 라벨이 달라. 웹은 Eaten out, Cooked at home, Seen at a market이고, 폰은 Eating out, Home, Market이야. 저장되는 값은 같은 네 단어고.

반대로 폰엔 웹에 없는 게 있어. outbox, 기기 사본, 네이티브 대화 화면. 두 클라이언트는 같은 엔진을 각자 자기 식으로 보여주는 거지, 서로를 베낀 게 아니야.

다른 클라이언트 얘기하는 주석이 제일 먼저 썩어

작은 함정 하나 짚고 갈게. 캡처 라우트의 독스트링은 아직도 웹 클라이언트가 사진을 요청 본문에 실어 보낸다고 말해. 폰 클라이언트엔 멀티파트 캡처 함수가 남아 있고, 그 주석은 이걸 웹 클라이언트 방식이라고 불러. 두 줄 다 1021db2에서 쓰였고, 15분 뒤 00c4e83이 웹을 스테이징으로 옮겼어. 지금 그 멀티파트 함수를 부르는 클라이언트는 없어. 라우트는 여전히 photos 본문을 받고 엔진 테스트도 그걸 쓰지만, 그걸 보내는 클라이언트는 하나도 없어. 코드가 옆 클라이언트 얘기를 하면, 믿기 전에 호출하는 자리부터 grep해 봐.

원칙: 라우트 목록을 인터페이스로, 클라이언트 하나하나를 그 구현 하나로 다뤄. 보내는 모양과 읽는 거절에서 전부 맞기만 하면, 보여주는 건 구현마다 달라도 돼.

oo-quest의 트랙 7이 다형성이라고 부르는 바로 그 생각을, 웹 페이지 하나와 폰 하나가 있는 제품에 그대로 쓴 거야.

Code

cwkSizzle, frontend/src/api.ts: 거절을 엔진 자기 말로 바꿔 주는 웹의 요청 헬퍼 (원문 그대로, 136-149행)·typescript
async function call<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(path, init);
  if (!response.ok) {
    let detail = `${response.status}`;
    try {
      const body = await response.json();
      detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
    } catch {
      /* keep the status */
    }
    throw new ApiError(detail);
  }
  return response.json() as Promise<T>;
}
cwkSizzle, clients/apple/SizzleMobile/Sources/EngineClient.swift: 폰도 문자열이든 검증 목록이든 같은 detail 필드를 읽어 (원문 그대로, 257-268행)·swift
    /// FastAPI puts the reason in `detail`, a string or a validation list.
    static func failure(_ reply: HTTPReply) -> Failure {
        var detail = "HTTP \(reply.statusCode)"
        if let object = try? JSONSerialization.jsonObject(with: reply.data) as? [String: Any] {
            if let text = object["detail"] as? String {
                detail = text
            } else if let items = object["detail"] as? [[String: Any]] {
                detail = items.compactMap { $0["msg"] as? String }.joined(separator: "; ")
            }
        }
        return Failure(status: reply.statusCode, detail: detail)
    }
cwkSizzle, clients/apple/SizzleMobile/Sources/EngineClient.swift: 트래픽 종류마다 하나씩, 세션 정책 셋 (원문 그대로, 17-21행)·swift
    static let imagePolicy = HTTPSessionPolicy(requestTimeout: 30, resourceTimeout: 60, byteBudget: 24 << 20)
    /// One staged photo (~1 MB) or a plate's small form: 60 s with no byte
    /// moving is a dead link, and 300 s covers a 1.5 MB photo at 5 KB/s.
    static let uploadPolicy = HTTPSessionPolicy(requestTimeout: 60, resourceTimeout: 300, byteBudget: 1 << 20)
    static let jsonPolicy = HTTPSessionPolicy(requestTimeout: 15, resourceTimeout: 30, byteBudget: 8 << 20)

External links

Exercise

클라이언트가 둘인 네 앱 하나를 골라 봐. 클라이언트 하나에 같은 서버를 부르는 스크립트 하나여도 괜찮아. 라우트 목록을 표로 적어. 메서드, 경로, 그 라우트가 실어 나르는 것. 그리고 라우트마다 어느 클라이언트가 부르는지 표시해. 한쪽만 부르는 라우트는 둘 중 하나야. 한 문장으로 적어 둘 수 있는 의도된 빈자리이거나, 빠진 기능이거나. 마지막으로 서버 거절을 두 클라이언트가 같은 필드에서 읽어 같은 메시지로 바꾸게 만들어.
Hint
문서나 주석을 믿지 말고 클라이언트마다 경로 문자열을 grep해. 함수는 있는데 부르는 자리가 없으면 안 부르는 걸로 쳐. 서버가 검증 목록을 돌려주면, 양쪽에서 메시지를 똑같이 이어 붙여서 사용자에겐 문장 하나만 보이게 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.