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

엔진은 집에 있어: 앱은 얇은 클라이언트야

~16 min · engine-at-home, thin-client, tailnet, architecture, endpoints

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"iOS가 사주는 건 OS 기능이지, 네트워크 도달 거리가 아니야."

기록하는 곳은 하나, 보는 창은 여러 개

폰이나 Mac 클라이언트가 있는 가족 앱은 구조가 전부 같아. 엔진은 집에 있는 Mac 한 대에서 돌고, 데이터베이스와 로그, 규칙까지 기록 전부를 쥐고 있어. 네이티브 앱은 얇은 클라이언트야. 사용자가 담고, 보고, 결정하는 창구 노릇을 하고, 엔진과는 tailnet으로 이야기해. tailnet은 가족 Mac, 폰, 아이패드를 이미 묶어둔 사설 네트워크야. 엔진으로 가는 공개 경로는 없어. Tailscale Funnel도 없고, 기기 안에 데이터베이스 사본을 하나 더 두지도 않아. tailnet이 없으면 앱도 없는 거고, 이걸 억지로 우회하지 않고 그대로 받아들여.

도달 거리를 넓혀주는 게 아니라면, 네이티브 클라이언트가 보태는 건 뭘까? 가족은 앱마다 매번 따지지 않으려고 목록을 적어뒀어. 믿을 수 있는 푸시, 공유 확장, App Intents와 동작 버튼, 선언한 백그라운드 모드, 담기 도구로 쓰는 카메라, 시스템이 지우지 않는 저장소, 백그라운드 오디오, 위젯과 라이브 액티비티, 햅틱, 그리고 웹 버튼에 맡기면 안 되는 일에 쓸 네이티브 컨트롤. 이 중 어느 것도 신뢰 경계를 바꾸지 않고, 기록을 쓰는 기계도 그대로야.

클라이언트가 엔진에 닿는 법

엔진은 자기 포트로 127.0.0.1에만 바인딩하고, 네트워크 인터페이스에서 직접 듣지 않아. 그 엔진을 Tailscale Serve가 tailnet에 쌍둥이로 내놔. 하나는 같은 포트 번호로 여는 평범한 HTTP고, 다른 하나는 바로 옆 포트에서 Mac의 tailnet 이름으로 된 인증서를 단 HTTPS야. 엔진이 있는 Mac에선 클라이언트가 루프백을 쓰면 되고, 다른 곳에선 tailnet 이름으로 쌍둥이 중 하나에 접속해. 쌍둥이마다 클라이언트가 치를 값(App Transport Security)과 Swift 엔진이 치를 값(어떻게 바인딩하는지)은 다음 레슨들에서 다뤄.

쌍둥이가 나란히 붙은 포트에 있으니까, 포트를 안 적은 주소는 소리 없이 엉뚱한 문을 두드리게 돼. 그래서 공유 엔드포인트 타입은 깐깐해. 받는 건 httphttps 스킴, 호스트 하나, 사용자가 직접 친 1부터 65535 사이의 포트, 그리고 끝의 슬래시뿐이야. 붙여 넣은 경로, 쿼리, 자격 증명은 조용히 버리지 않고 이유와 함께 거절해. 루프백인지, PIN이 어느 키체인 계정에 있는지 같은 주소에 관한 질문은 전부 파싱한 호스트를 보고 대답해. 어떤 앱은 "루프백이야?"를 친 글자 그대로 판단하고 다른 메서드는 파싱한 값을 쓰는 바람에, 로컬 주소인데도 PIN을 요구한 적이 있거든.

엔진이 앱보다 옛 버전일 때

폰 앱은 애플 일정에 따라 TestFlight로 업데이트되지만, 엔진은 누군가 재시작해야 업데이트돼. 여행 일지 앱의 새 빌드에서 기록 하나가 "Method Not Allowed"에 걸려 멈췄어. 요청 자체는 멀쩡했어. 집에 있는 엔진은 새 경로가 없는 채로 며칠째 돌고 있었어. 그 엔진은 /에 GET과 HEAD만 받는 정적 파일 마운트를 두고 웹 클라이언트를 내주는데, 모르는 POST가 거기로 떨어지니까 404가 아니라 405가 돌아온 거야. 엔진을 재시작하니 해결됐어. 하지만 오래 남을 수정은 클라이언트 쪽에 들어갔어. 이제 클라이언트는 엔진이 아마 앱보다 옛 버전일 거라고 말해주고, 재시작 명령 이름도 알려줘. 그 기록은 거절된 게 아니라 다시 시도할 대상으로 다루고. 클라이언트와 엔진을 함께 바꾸면 이런 어긋남은 매번 생긴다고 생각해.

Code

깐깐한 엔진 주소: 스킴, 호스트, 직접 적은 포트, 그 밖엔 아무것도 없이·swift
import Foundation

struct EngineLocation: Equatable, Sendable {
    enum Failure: Error, Equatable {
        case scheme, host, portMissing, portRange, extraParts
    }

    let scheme: String   // "http" or "https"
    let host: String     // IPv6 keeps its brackets: "[::1]"
    let port: Int        // always written: the twins sit on adjacent ports

    var baseURL: URL { URL(string: "\(scheme)://\(host):\(port)")! }

    /// Strict: a scheme, a host and a WRITTEN port, nothing else but a trailing "/".
    init(parsing text: String) throws(Failure) {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard let components = URLComponents(string: trimmed),
              let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https"
        else { throw .scheme }
        guard components.user == nil, components.password == nil,
              components.query == nil, components.fragment == nil,
              components.path.isEmpty || components.path == "/"
        else { throw .extraParts }
        guard let rawHost = components.percentEncodedHost, !rawHost.isEmpty else { throw .host }
        guard let port = components.port else { throw .portMissing }
        guard (1...65_535).contains(port) else { throw .portRange }
        self.scheme = scheme
        self.host = rawHost.contains(":") && !rawHost.hasPrefix("[") ? "[\(rawHost)]" : rawHost
        self.port = port
    }

    /// Answered from the parsed host, never the typed string. "127.evil.com" is a name, not loopback.
    var isLoopback: Bool {
        let bare = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased()
        let octets = bare.split(separator: ".", omittingEmptySubsequences: false)
        let ipv4Loopback = octets.count == 4 && octets.allSatisfy { UInt8($0) != nil } && octets[0] == "127"
        return bare == "localhost" || bare == "::1" || ipv4Loopback
    }
}

// "http://office.example.ts.net:8900"  -> ok
// "http://office.example.ts.net"       -> portMissing
// "http://127.0.0.1:8900/api"          -> extraParts
// "HTTP://[::1]:8900"                  -> ok, isLoopback == true

External links

Exercise

SparkKit에 EngineLocation을 넣고 셀프 테스트를 붙여. 주석에 적힌 네 경우와 http://user@host:1, ftp://host:21, http://host:0, https://host:8901/의 결과를 고정하면 돼. Spark 설정 화면에 엔진 주소 텍스트 필드 하나를 두고, 실패 이유를 필드 아래 보여줘. 마지막으로 Spark 클라이언트가 엔진에게서 405를 받으면 뭘 해야 하는지 README에 한 문장으로 정하고, 그 메시지를 구현해.
Hint
포트를 안 적으면 URLComponents.port가 nil인데, 바로 그게 거절해야 할 경우야. 실패 이유는 enum 케이스로 두고, 문장으로 바꾸는 건 뷰에서 해. 그래야 셀프 테스트에서 문장을 비교할 일이 없어.

Progress

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

댓글 0

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

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