본문 바로가기
C.W.K.
Stream
Lesson 02 of 07 · published

어떤 엔진도 위로 새지 않아

~11 min · stt-adapter, leaky-abstraction, contract, engine

Level 0불씨 없음
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"이음새 위에 'WhisperKit' 이란 단어가 나타나면, 추상화는 이미 실패한 거야."

어댑터의 유일한 일

STT 어댑터가 할 일은 자기 엔진을 인터페이스 처럼 보이게 하는 거야. 그 이상은 없어. WhisperKit 엔 자기만의 디코딩 옵션이랑 모델 enum, 오디오 포맷 요구가 있어. SpeechAnalyzer 는 로케일 에셋이랑 async 결과 스트림을 내밀고. 그중 어느 것도 이음새 위로 새면 안 돼. 어댑터가 자기 엔진의 별난 점을 다 삼키고 공유 계약만 내놔. 녹음이랑 설정(언어 모드도 여기 들어 있어)을 주면 텍스트랑 언어를 돌려줘(WhisperKit 이면 감지한 언어, SpeechAnalyzer 면 돌던 로케일). 그게 전부야. 컨트롤러는 any STTEngine 을 쥐고 있을 뿐 자기가 누구랑 얘기하는지 끝까지 안 배워.

누수 테스트

추상화가 진짜인지 확인하는 구체적인 방법이 있어. 이음새 위 코드를 엔진 이름으로 grep 해 봐. DecodingOptions 나 WhisperKitConfig, 모델 파일명이 DictationController 에서 나오면 추상화가 새는 거야. 컨트롤러가 모른다고 해 놓고 실은 그 디테일에 기대고 있는 거니까. 깨끗한 어댑터라면 그 단어들이 어댑터 파일 안에서만 나와. 그 위는 전부 중립 계약만 말하고.

누수 (나쁨):
  컨트롤러 -> WhisperKit 의 DecodingOptions 직접 읽음   # 엔진이 위로 샘

깨끗 (좋음):
  컨트롤러 -> STTEngine.transcribe(audioURL, settings)     # 중립 계약
  WhisperKitEngine -> DecodingOptions 내부에서 만듦          # 별난 점 흡수

계약은 하류가 필요한 걸 담아

반환 타입은 일부러 작게, 대신 빠짐없이 만들었어. 텍스트, 그리고 언어. WhisperKit 이면 감지했거나 지정받은 언어고, 오디오 언어를 알아내지 못하는 SpeechAnalyzer 면 자기가 돌던 로케일이야. 왜 언어까지 실을까. 바로 다음 단계인 정리가 그게 필요하고, 그걸 아는 컴포넌트가 엔진뿐이거든. 어댑터가 언어를 떨구면 정리가 다시 감지하거나 추측해야 해. 엔진이 이미 답한 걸 다시 묻는 거지. 좋은 계약은 하류가 필요한 걸 정확히 담고 그 이상은 안 담아. 엔진 내부 확신 점수도, 토큰 타이밍도 아니고, 앱 나머지가 굴러가는 데 필요한 두 가지 사실만.

어댑터는 별난 점을 자기가 삼켜. 위로 넘기지 않고. 좋은 어댑터인지 재는 잣대는 엔진의 특이한 점이 그 경계에서 딱 멈추느냐야. 어댑터를 쓰려고 호출자가 엔진을 알아야 한다면, 추상화가 아니라 그냥 지나가는 통로를 지은 거야. 설정도, 포맷도, 에러 모양도 다 흡수해. 밖으로는 중립 계약만.
내가 처음 쓴 WhisperKit 어댑터는 샜어. 컨트롤러가 '지금만' 이라면서 디코딩 옵션을 설정하러 손을 뻗고 있었거든. SpeechAnalyzer 엔진을 붙이기 전까진 별문제 없어 보였어. 붙이는 순간 컨트롤러에 분기가 필요해졌지. 'WhisperKit 이면 이 옵션, SpeechAnalyzer 면 저 옵션.' 그 분기가 누수 때문에 치른 값이었어. 옵션을 각 어댑터 안으로 되돌리니까 분기가 사라졌고, 결합도 같이 사라졌어.

Code

엔진의 별난 점이 어댑터 경계에서 멈춰·swift
@MainActor
final class WhisperKitEngine: STTEngine {
    private var pipeline: WhisperKit?

    func transcribe(audioURL: URL, settings: AppSettings) async throws -> STTResult {
        let pipe = try await loadPipeline(settings: settings, onProgress: { _ in })
        // WhisperKit 전용 설정은 전부 여기서 만듦, 절대 노출 안 함.
        var options = DecodingOptions()
        options.task = .transcribe
        options.temperature = 0
        options.usePrefillPrompt = true
        options.chunkingStrategy = .vad
        if let code = settings.dictationLanguage.whisperLanguageCode {
            options.language = code          // "ko" 아니면 "en"
            options.detectLanguage = false
        } else {
            options.detectLanguage = true    // Auto: 오디오에서 알아냄
        }
        let results = try await pipe.transcribe(audioPath: audioURL.path, decodeOptions: options)
        let text = results.map { $0.text }.joined(separator: " ")
        return STTResult(text: text, detectedLanguage: results.first?.language)
    }
}
// 컨트롤러는 STTResult 를 봐. DecodingOptions 는 절대 안 봐.

External links

Exercise

네 코드의 어댑터나 래퍼를 하나 골라서, 그 위 코드를 밑바탕 라이브러리의 타입 이름으로 grep 해 봐. 걸리는 건 전부 누수야. 하나 골라서 밑바탕 라이브러리를 갈아끼우려 할 때 뭐가 깨지는지 적어봐. 그 깨지는 게 정확히 누수가 네게 물리는 값이야.
Hint
새어 나온 타입 이름 하나하나가 호출자가 특정 라이브러리에 몰래 기대고 있는 자리야. 라이브러리를 갈아끼우면 그 자리를 전부 손봐야 하고. 어댑터가 막았어야 할 결합이지. grep 에 걸린 개수가 곧 교체가 실제로 물릴 값이야.

Progress

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

댓글 0

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

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