챗봇을 넘어서: 퇴근길을 중심에 둔 추천 PoC
먼저 밝혀 둘 것. 이 시리즈가 다루는 것은 완성된 서비스가 아니라 PoC(Proof of Concept) 다. 계정도, 프로필도, 알림도 없다. 게스트 한 명이 30초 안에 네 가지를 입력하고 지도를 보는 세로 슬라이스 하나가 전부다. 이 글의 모든 설계 결정은 “가설 하나를 가장 싸게, 그러나 정직하게 검증한다”는 목표 아래에서 읽어야 한다.
어려운 건 강좌를 찾는 일이 아니다
한국에는 평생학습 데이터가 꽤 많다. 전국평생학습강좌표준데이터(data.go.kr 15013110) 하나만 해도 공공 지원 강좌 수만 건이 올라와 있고, 스포츠강좌이용권(KSPO)은 요일 비트마스크와 가격까지 정확하게 제공한다. 카탈로그는 이미 존재하고, 무료이고, 검색도 된다.
그런데 이 제품이 대상으로 하는 사람 — 저녁 6시 40분에 지하철 승강장에 서서 “오늘 저녁에 뭔가 할 만한 게 있을까”를 고민하는 30대 직장인 — 에게는 거의 쓸모가 없다. 카탈로그는 “이 구에 어떤 강좌가 있는가?” 에 답하지만, 사용자가 묻는 질문은 다르다.
회사에서 이 강좌까지 가서, 수업을 듣고, 집까지 돌아올 수 있는가 — 오늘 저녁에, 이번 주에, 그리고 계속?
이건 검색 쿼리가 아니다. 서로 성격이 다른 세 축 위의 실현 가능성 제약이고, 어떤 공공 카탈로그도 이를 모델링하지 않는다.
사용자는 점이 아니라 선분이다
가장 먼저 떠오르는 설계는 반경 검색이다. 사용자를 지오코딩하고, 반경을 그리고, 그 안에 들어오는 것을 돌려준다.
직장인에게는 이 방식이 특정한 방식으로 실패한다. 직장인의 기준점은 하나가 아니라 둘이다. 집과 회사이고, 그 사이의 선을 하루에 두 번 지나간다. 이미 지나가는 경로에서 2분만 벗어나면 되는 강좌는 집에서 500m 떨어진 강좌보다 더 편하다. 반대로 집에서 500m지만 회사 반대 방향인 강좌는, 퇴근 동선 위에 있는 3km짜리 강좌보다 오히려 다니기 어려울 수 있다.
반경 검색은 이걸 표현하지 못한다. 사용자는 선분인데 점으로 취급하기 때문이다. 이 재정의가 곧 제품 가설이고, PoC가 존재하는 이유다.
게스트가 자신의 퇴근 동선 위에 실제 강좌가 놓인 지도를 보고, 강좌를 눌러 저녁 경로를 다시 최적화해 볼 수 있다면 — 로그인할 만한 가치를 느낀다.
이 가설이 맞으면 ‘지도 위 동선 매칭’은 진짜 차별화 축이다. 틀리면 방향을 재고해야 한다. 나머지 모든 엔지니어링은 이 한 문장을 검증하기 위한 하위 작업이다.
그래서 게스트 입력도 정확히 네 가지로 줄였다. 집 동네, 회사 동네, 선호 시간대, 관심 대분야 하나. 상세 주소도, 정확한 시간도, 프로필도 받지 않는다. 동네 수준 위치만으로도 쓸 만한 동선 회랑을 계산할 수 있고, 집 주소보다 훨씬 덜 민감하기 때문이다.
세 가지 제약군은 서로 다르게 실패한다
‘다닐 수 있는가’는 세 갈래로 분해되는데, 셋은 불확실성 앞에서 전혀 다르게 행동한다.
- 의미(Semantic) — 무엇을 배우고 싶은가? 모호하고, 주관적이고, 표현이 제각각이다. “코딩”, “파이썬 입문”, “데이터 분석 기초”는 같은 의도다. “AI 캘리그라피”는 제목에 AI가 있지만 예술이다. 여기서는 의미를 매칭하는 게 아니라 계산해야 한다.
- 지리(Geographic) — 물리적으로 갈 수 있는가? 단단하고, 계산 가능하고, 봐주는 게 없다. 좌표가 있거나 없거나, 우회 예산 안이거나 밖이거나 둘 중 하나다.
- 시간(Temporal) — 일정이 맞는가? 데이터가 있는 곳에서는 단단하지만, 아예 없는 경우가 많다. 상당수 공공 레코드는 자유 텍스트(“매주 화,목”)이고, 일부만 정확한 비트마스크를 담고 있다.
위험한 선택은 이 셋을 똑같이 다루는 것이다. 언어 모델이 세 축을 균일하게 추론하게 두면, 엉뚱한 구에 있는 만료된 강좌를 사용자가 갈 수 없는 시간에, 유창한 한국어와 그럴듯한 설명을 붙여 자신 있게 추천한다.
LLM 프롬프트 하나로 안 되는 이유
2026년에 가장 솔깃한 아키텍처는 호출 한 번이다. 카탈로그를 컨텍스트에 넣고, 사용자를 설명하고, 추천을 요청하는 것. 여기서는 중요한 축마다 전부 실패한다.
규모. 후보 코퍼스는 약 2만 7천 건이고 매일 밤 갱신된다. 검색(retrieval)은 선택이 아니다.
비용과 지연. 추천과 지도 응답의 목표는 3초 이내다. 그리고 공공데이터 소스에는 1일 1,000건 개발 계정 한도가 있어, 요청마다 원본을 호출하는 것 자체가 불가능하다.
권한(Authority). 이게 결정적이다. 수강 가능 여부는 세계에 대한 사실 주장이다. 강좌가 존재하는지, 만료됐는지, 어디에 있는지, 언제 열리는지는 판단의 문제가 아니다. 이런 사실을 생성할 수 있는 확률적 시스템은 그것을 틀리게 생성할 수도 있고, 사용자는 그 차이를 구분할 방법이 없다.
시스템 전체 흐름: 좁혀 가는 단계들의 사슬
그래서 시스템은 하나의 모델이 아니라 좁혀 가는 단계들의 사슬이다. 각 화살표는 좁히는 단계이고, 상위 단계가 배제한 것을 하위 단계가 다시 넓힐 수 없다 — 이 불변식이 시리즈 전체의 핵심이다.
왼쪽 절반은 오프라인 ML 데이터 파이프라인이다. 원본에서 수집하고, 정규화하고, 임베딩하고, 분류하고, DB에 적재한다. 사용자 요청과 무관하게 매일 밤 한 번 돈다(Chapter 4). 오른쪽 절반은 온라인 추천 경로다. 하드 게이트 → 하이브리드 검색(Chapter 2) → 결정론적 랭킹 → 제한형 LLM 큐레이션(Chapter 3). 두 절반은 DB 스냅샷 하나로만 만나고, 런타임은 절대 원본 API를 호출하지 않는다.
이 그림에서 웹 프레임워크(Next.js)는 의도적으로 맨 아래 한 줄이다. 서버 프록시와 타입 안전한 경계를 주지만, 이 시리즈의 관심사는 그 위에 얹힌 ML 시스템 쪽이다.
AI가 기여하는 곳, 권한을 가지면 안 되는 곳
설계는 여기에 분명한 선을 긋는다.
AI가 실제로 가치를 더하는 곳 — 공유 분류 체계가 없는 여러 소스 어휘를 다섯 개 카테고리로 수렴시키는 의미 정규화, 제목밖에 없는 희소 레코드의 분류, 문자열 겹침이 아니라 의도 유사도로 순위를 매기는 검색, “집에 가까운 쪽? 회사에 가까운 쪽?” 같은 주관적 선호 정제, 그리고 구조화된 사실을 정직한 한 문장으로 바꾸는 근거 설명.
AI가 절대 권한을 가지면 안 되는 곳 — 강좌의 존재 여부, 수강 자격, 일정, 위치, 수강신청 관련 사실과 링크.
이건 시리즈 내내 반복해서 돌아오는 아키텍처 규칙이 된다.
결정론적 로직이 자격을 소유한다. 확률적 시스템은 후보를 풍부하게 하거나 재정렬할 수 있지만, 자격 없는 강좌를 되살릴 수는 없다.
구체적으로: SQL 게이트에서 제외된 강좌 — 만료, 카테고리 불일치, 우회 예산 초과, 일정 명백 불가 — 는 후보 집합에 없다. 모델의 도구 표면에도 없다. 출력 스키마의 ID enum에도 없다. 모델은 그것을 되살릴 수 없는데, 애초에 거기로 가는 경로를 받은 적이 없기 때문이다. 시스템 프롬프트에 “잘 행동해 달라”고 적어 두는 게 아니라 제어 흐름과 닫힌 스키마로 강제한다.
오프라인이 기본, 라이브는 옵트인
PoC의 크리티컬 패스 병목은 코드가 아니라 외부 키였다. data.go.kr 키, KSPO, 카카오 JS·REST 키, Upstage 키 — 전부 발급이 필요했고 카카오는 도메인 등록까지 필요했다.
그래서 모든 외부 seam은 기본이 오프라인 fixture이고, 라이브로 가려면 명시적 플래그가 필요하다. 임베딩 게이트가 대표적이다. 환경에 UPSTAGE_API_KEY가 있다는 사실만으로는 절대 과금되는 라이브 호출이 일어나지 않고, SEMANTIC_EMBEDDING_PROVIDER=upstage-live를 함께 명시해야 한다. 크리덴셜을 가진 것과 그것을 쓰겠다는 의도는 다른 상태이고, 이 둘을 뭉뚱그리는 것이 스테이징 배포가 조용히 프로덕션 쿼터를 태우는 방식이다.
우회책으로 시작한 이 결정이 네 가지로 되돌아왔다.
- 개발이 막히지 않았다 — 키 발급, 활성화 지연, 도메인 등록 어느 것에도.
- 테스트가 결정론적이고 크리덴셜 프리다. fixture 임베딩 프로바이더는 SHA-256 카운터 확장으로 벡터를 유도하므로 같은 입력은 항상 같은 벡터를 낸다.
- E2E가 밀폐적이다.
.env.local에 실제 키가 있어도 E2E는 fixture 모드를 강제한다. - 프로덕션 degradation이 이미 구현되어 있다. 오프라인 경로는 스텁이 아니라, 프로바이더가 죽었을 때 시스템이 실제로 떨어지는 바로 그 경로다.
마지막이 진짜 수확이다. 프로덕션에서만 존재하는 폴백은 한 번도 테스트된 적 없는 폴백이다.
실패는 미리 설계한다
확률적이거나 외부에 의존하는 모든 것에 정의되고 테스트된 실패 모드가 있다.
| 실패 | 동작 |
|---|---|
| 카카오 경로 불가 | 폴백 사다리: 신선한 캐시 → fetch → 오래된 캐시(라벨링) → 직선 회랑. 후보 집합은 영향 없음 |
| 임베딩 프로바이더 다운 | 쿼리 벡터 null → 의미 성분 중립(0.5), 0이 아님. 전체 목록 그대로 반환 |
| LLM 불가 / 스키마 실패 / 근거 검증 실패 | 단일 폴백 퍼널 → 결정론적 목록 + 고정 안내 문구 |
| DB 불가 | 스냅샷 읽기 → fixture 읽기 |
| 야간 배치 실패 | 마지막 정상 스냅샷 보존, 다음 날 재시도. 서빙은 영향 없음 |
두 가지 원칙이 이 표를 관통한다.
0이 아니라 중립. 누락된 의미 신호는 0이 아니라 0.5로 매긴다. 0으로 매기면 임베딩이 없는 모든 강좌를 조용히 벌주는 것이고, 데이터 공백을 랭킹 의견으로 바꾸는 일이다.
버그는 폴백이 아니다. 폴백 사다리는 통제된 오류 타입에만 degrade하고, 그 외의 throw는 전파된다. 사다리를 통째로 catch로 감싸면 모든 버그가 조용한 품질 저하로 바뀌고, 시스템은 약간 더 나쁜 상태로 영원히 서비스하며, 아무도 호출되지 않는다.
PoC에서 “성공”이란
성공 신호는 정확도 지표가 아니라 행동 지표다. 흐름 완주율, 지도 상호작용(핀 클릭, 경로 재최적화), 전환 게이트 클릭 — 그리고 정성적 부정 신호. “너무 멀다”와 “시간이 안 맞는다”의 비율은 매칭이 나쁜 것과 전제가 틀린 것을 구분해 준다.
마지막 항목이 가장 중요하다. 사용자가 “멀다”고 하면 회랑 모델을 손보면 된다. “이걸 왜 봐야 하지?”라고 하면 가설이 틀린 것이다. 이 둘은 완주율 대시보드에서는 똑같아 보이지만 대화에서는 완전히 다르다.
남은 이야기
이 제품에서 어려웠던 건 강좌를 찾는 일이 아니었다. 파편화되고, 라벨이 제각각이고, 좌표가 절반쯤 붙어 있는 공공 레코드를, 직장인이 믿고 행동할 만한 추천으로 바꾸는 일이었다. 그리고 그것을 PoC 예산 안에서 — 무료 티어, 홈 서버 한 대, 개발 계정 쿼터 — 해내는 일이었다.