본문으로 건너뛰기
Chandler Nguyen
AI5분 읽기

AI

제 AI 앱의 오프라인 모드에는 AI가 필요 없었어요. 필요했던 건 '지루함'이었죠.

DIALØGUE의 iOS 앱을 위해 똑똑하고 AI에 특화된 오프라인 모드를 만들 뻔했어요. 그러다 좀 더 지루한 질문을 던졌죠 — iOS가 이미 나한테 뭘 주고 있지? — 그랬더니 진짜 어려운 일은 똑똑해지고 싶은 충동을 참는 거였어요. '에피소드를 다운로드해서 비행기에서 듣는다'는 것 뒤에 있는, 표준적이고 전혀 화려하지 않은 장치들, 그리고 왜 지루한 쪽을 고르는 게 더 노련한 선택이었는지 적어볼게요.

최근에 DIALØGUE의 iOS 앱에 오프라인 다운로드를 출시했어요. 지난 글에서 "탄탄한 오프라인"이라고 손짓하듯 슬쩍 언급만 하고, 그게 무슨 뜻인지는 끝내 설명하지 않았던 바로 그 부분이에요. 이 글이 그 설명입니다. 그리고 이 이야기는, 제가 하마터면 엉뚱한 걸 만들 뻔했다는 데서 시작해요.

고백할게요. DIALØGUE는 AI 팟캐스트 앱이에요. 주제나 PDF를 주면, 두 진행자가 나누는 대화를 생성해 줍니다. 그래서 오프라인 모드를 시작했을 때, 제 머리는 곧장 'AI스러운' 무언가로 튀었어요. 어쩌면 "오프라인"이란 기기 위에서 오디오를 미리 생성해 두는 걸지도 모른다. 어쩌면 모델 출력을 캐싱하는 거나, 네트워크 없이도 에피소드를 다시 조립해 내는 똑똑한 파이프라인 같은 거. 머릿속에서 통째로 하나의 아키텍처가 만들어지고 있었어요. 하마터면 그 방향으로 설계 문서를 쓸 뻔했죠.

그러다 멈춰서, 좀 더 지루한 질문을 던졌어요: 이 일에 대해, iOS가 이미 나한테 뭘 주고 있지?

답은, 막상 열어 보니 "거의 전부"였어요. 그리고 이 기능의 진짜 일은 뭔가 똑똑한 걸 만드는 게 아니었어요. 그 충동을 참는 거였죠.

그 지루한 질문이 제 한 달을 아껴줬어요

이건 제가 글을 쓸 때마다 거듭 다시 배우는 거예요: 복잡함은 당신의 친구가 아니에요. 특히 혼자서, 저녁과 주말에 만들고 있을 때는요.

AI 팟캐스트는, 일단 생성되고 나면, 어떤 URL 위에 놓인 그냥 오디오 파일일 뿐이에요. "오프라인"은 그걸 다시 생성하는 게 아니에요. 그 파일을 다운로드해서 나중에 재생하는 거죠. 이건 AI 문제가 아니에요. App Store가 존재하기도 전부터 애플이 줄곧 풀어온 문제예요.

그래서 똑똑한 파이프라인 대신, 저는 연장통에서 가장 지루하고 가장 검증된 도구로 손을 뻗었어요: **background URLSession**이요. 혹시 만나본 적이 없다면, 발상은 단순해요 — 다운로드를 운영체제에 넘기면, 앱이 백그라운드에 있거나 심지어 종료됐더라도 계속 진행되고, 끝나면 앱을 다시 실행시켜서 완성된 파일을 건네줘요.

let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true

대략 두 줄이면, 이제 정말로 어려운 부분은 운영체제가 가져가요. 저는 다운로드 엔진을 쓰지 않았어요. 애플 걸 빌렸죠.

제가 보는 곳마다 똑같은 이야기가 반복됐어요:

  • 다운로드가 중간에 끊겼다고요? iOS가 "resume data"를 건네줘요 — 다운로드가 0부터 다시 시작하는 대신 멈춘 자리에서 이어가게 해주는 작은 데이터 덩어리예요. 저는 그냥 그걸 디스크에 써두고(버려도 되는 거라 Caches 폴더에요), 다음번에 다시 넘겨주기만 하면 돼요.
  • 사용자의 셀룰러 데이터를 태우고 싶지 않다고요? 한 줄이에요: request.allowsCellularAccess = !wifiOnly. 그다음엔 운영체제가 알아서 WiFi를 기다려요. 저는 연결 상태를 모니터링하지 않아요. 아무것도 폴링하지 않고요.
  • 다운로드가 공간을 얼마나 차지하는지 보여줘야 한다고요? ByteCountFormatter가 바이트 수를 올바르게 현지화된 단위가 붙은 "23.4 MB"로 바꿔줘요. 그것도 제가 쓴 게 아니에요.

이게 얼마나 기분 좋은지 솔직하게 말해두고 싶어요. 직관과는 반대되는 일이거든요. 돌이켜보면, 옳은 수는 더 똑똑한 코드가 아니었어요. 이게 이미 풀린 문제라는 걸 인정하고, 그걸 머리로 이겨먹으려 하는 대신 그 작업을 빌려오는 것이었어요.

하지만 "지루함"이 "쉬움"을 뜻하진 않았어요

이제 한 가지 인정해야 해요. 표준적인 방식 덕분에 이게 사소한 일이 됐다고 말한다면, 그건 거짓말이거든요 — 그리고 이 글을 읽는 iOS 개발자라면 누구든 한 십 초 만에 저를 잡아낼 거예요.

표준이라는 건 사소하다는 것과 같지 않아요. 지루한 도구들이 지루한 건, 마찰이 없어서가 아니라 닳도록 많이 쓰여서예요. 실제로 제 시간을 잡아먹은 것들을 적어둘게요. 혹시 이 목록이 당신의 시간을 좀 아껴줄까 해서요:

1. 임시 파일이 당신이 보고 있는 사이에 사라져요. 백그라운드 다운로드가 끝나면, iOS가 콜백을 호출하면서 임시 위치에 있는 파일을 가리켜요. 아무도 말 안 해주는 함정: 그 파일은 당신의 콜백이 return하는 그 순간 삭제돼요. 다른 어떤 것도 하기 전에, 바로 그 함수 안에서, 동기적으로 그걸 영구적인 곳으로 옮겨야 해요. 더 곤란한 건, 그 콜백이 메인 스레드 밖에서 돌고 있고, 어떤 다운로드가 끝났는지만 알 뿐 그게 제 앱의 말로 어떤 에피소드인지는 모른다는 거예요. 그래서 코드는 못생긴 2단 스텝을 밟아요: 파일을 일단 임시 이름으로 바로 옮겨두고, 그게 뭔지 알게 되면 메인 스레드로 건너가서 이름을 바꿔주는 거죠. 예쁘지 않아요. 하지만 다른 길은, 저장하기도 전에 사라져버린 파일이에요.

2. 시뮬레이터에서는 백그라운드 세션이 소리 없이 아무것도 안 해요. 저는 제 코드가 망가졌다고 확신한 채로 부끄러울 만큼 긴 시간을 보냈어요. 안 망가졌더라고요. background URLSession은 iOS 시뮬레이터에서 그냥 신뢰할 수 없게 동작해요 — 에러도 없고, 크래시도 없고, 아무것도 다운로드되지 않아요. 해결책은 못생겼고, 제가 인정할게요:

#if DEBUG
// Use a default session in the simulator — background sessions can silently fail
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
#else
session = URLSession(configuration: .background(withIdentifier: identifier), delegate: self, delegateQueue: nil)
#endif

#if DEBUG는 우아하지 않아요. 하지만 시뮬레이터가 실제 기기처럼 동작하는 척하는 건, 그렇지 않다고 인정하는 것보다 저한테 훨씬 더 비싸게 먹혔을 거예요.

3. Swift 6는 누가 어느 스레드에서 도는지 정확히 말하게 만들었어요. 다운로드 콜백은 백그라운드 스레드로 도착해요. 제 UI 상태 — 작은 진행 링들, 초록색 "다운로드됨" 체크 표시 — 는 메인 스레드에서 업데이트돼야 하고요. Swift 6의 더 엄격한 동시성 검사는 그 경계에서 제가 대충 넘어가는 걸 허락하지 않았어요. 결국 제목이 말 그대로 *"resolve Swift 6 actor-isolation warnings"*인 정리용 커밋이 하나 남았죠. 그 순간엔 짜증났고, 돌아보면 옳았어요. 컴파일러가 맞았고, 틀린 건 저였어요.

4. 서버가 파일 이름을 항상 깔끔하게 지어주진 않아요. 오디오는 어떨 땐 audio/mpeg로, 어떨 땐 m4a로, 가끔은 또 다른 무언가로 돌아와요. 그래서 응답의 content type에서 파일 확장자를 추론하고, 안 되면 URL로 물러나고, 그래도 안 되면 .mp3를 기본값으로 쓰는 작고 밋밋한 함수가 하나 있어요. 화려하지 않아요. 필요하죠.

이 중 어느 것도 AI 문제가 아니에요. 하나하나 다 "컴퓨터가 실제로 어떻게 돌아가는가"의 문제예요. 그게 지루한 길을 갈 때 내는 세금이에요. 진짜로 내야 하는 거죠 — 하지만 제가 직접 발명한 똑똑한 무언가를 유지보수하는 비용을 치르느니, 저는 매번 이 세금을 내겠어요.

제가 일부러 만들지 않은 것

이 기능 전체에서 가장 쓸모 있는 부분은, 제가 하지 않은 것들의 목록이라고 생각해요:

  • 다운로드 엔진을 쓰지 않았어요. (background URLSession.)
  • 연결 상태 모니터를 쓰지 않았어요. (allowsCellularAccess.)
  • 이어받기 프로토콜을 발명하지 않았어요. (운영체제의 resume data.)
  • AI에 특화된 그 무엇도 만들지 않았어요. (그냥 파일이에요. 다운로드되죠.)

제가 정말로 공을 들인 건 작고 화려하지 않은 것들이었어요: 동시에 세 개를 넘는 다운로드가 돌지 않고 나머지는 차례를 기다리게 하는 작은 큐. 콜드 스타트 후에 저장 공간 화면이 "에피소드, 에피소드, 에피소드"가 아니라 진짜 이름을 보여주도록 에피소드 제목을 저장해 두는 일. 공간을 비우려 할 때 가장 무거운 범인들이 맨 위에 오도록, 그 화면을 파일 큰 순서대로 정렬하는 일. 그리고 진짜 판단들 — 빈 슬롯이 몇 개인지, 목록을 어떻게 정렬할지 — 은 URLSession이 전혀 붙어 있지 않은 순수 함수로 빼냈어요. 제대로 유닛 테스트할 수 있도록요. 지루해요. 테스트 가능하고요. 테스트 가능하기 때문에 지루한 거예요.

그 교훈, 대략 백 번째쯤

지금까지 저는 이 글의 어떤 버전을 여러 번 써왔어요 — 제 웹사이트를 다시 만든 이야기, 54달러짜리 API 청구서 이야기, 바로 이 앱을 다시 만든 이야기. 교훈은 늘 똑같고, 저는 계속 그걸 다시 배워야 해요: 똑똑한 해법은 대개 비싼 해법이에요. "플랫폼이 이미 나한테 뭘 주고 있지?" — 이게 코드 한 줄을 쓰기 전에 제가 던질 수 있는 가장 값진 질문이에요.

특히 혼자 만드는 사람한테는요: 당신이 발명하는 똑똑한 것 하나하나는, 그게 가장 최악의 순간에 망가졌을 때 당신 혼자 유지보수해야 하는 것이에요. 당신이 빌려오는 지루하고 표준적인 것 하나하나는, 뒤에 수천 명의 엔지니어를 둔 회사가 유지보수해 주는 것이고요. 제 경험상, 그 거래는 거의 항상 할 만한 가치가 있어요 — 그리고 솔직히, 그 거래를 해내는 규율이 그게 대체하는 똑똑함보다 더 어려워요. 그래도, 그 선이 정확히 어디에 그어지는지에 대해선 제가 틀렸을 수도 있어요. 분명 플랫폼이 아무것도 주지 않아서 어려운 부분을 직접 만들어야 하는 문제들도 있겠죠 — 그저 제 직관이 우기는 것보다 그런 일이 훨씬 드물다는 걸, 저는 계속 발견하고 있을 뿐이에요.

DIALØGUE를 쓰는 분들 중 몇몇은 이제 에피소드를 다운로드해서, 비행기에 올라, 신호 없이 들을 수 있어요. 의미 있는 기능처럼 느껴져요. 그 뒤의 코드는 거의 공격적일 만큼 평범하고요. 저는 그 점과 화해했어요. 그게 바로 일이라고 생각해요.

당신도 이러시나요 — 지루한 쪽이 바로 거기 있었는데, 똑똑한 쪽으로 손을 뻗는 자신을 발견하는 것 말이에요. 아니면 그냥 제가 느려서, 같은 교훈을 자꾸자꾸 다시 배우는 걸까요? 다른 분들은 이 본능과 어떻게 싸우는지 정말로 듣고 싶어요.

감사합니다, Chandler