버튼은 잘 돌았다. 문제는 그 버튼 한 번이 얼마인지 아무도 몰랐다는 것이다.
AI 식단 짜기가 처음으로 끝까지 동작한 날, 미리보기에 2주치 식단이 깔끔하게 채워진 걸 보고 나서야 다른 질문이 떠올랐다. 지금 이 한 번에 토큰이 몇 개 들었지? 사용자가 열 명이면, 백 명이면 한 달에 얼마가 나가지? 식단서랍에는 광고도 유료 요금제도 없다. 들어오는 돈은 없는데 나가는 돈은 사용자 수에 비례해 늘어나는 구조였다. 그 숫자를 모르는 채로 버튼을 열어 둘 수는 없었다.
먼저 적어 두기로 했다
언어 모델 API 는 응답마다 입력 토큰과 출력 토큰 수를 돌려준다. 비용은 결국 그 두 숫자에 모델별 단가를 곱한 값이다. 그러니 첫 번째 일은 호출할 때마다 그 숫자를 남기는 것이었다.
ai_usage_logs 테이블을 만들고 호출 한 번에 한 줄씩 쌓았다. 누가, 어떤 기능으로, 어떤 모델에, 입력·출력 토큰을 얼마나 썼고, 몇 밀리초가 걸렸고, 성공했는지, 실패했다면 왜인지. 기록 저장이 실패해도 기능은 막지 않는다.
이 기록을 모아 관리자 화면에 AI 사용량 페이지를 붙였다. 기간을 고르면 일별 호출 수, 기능별·모델별 합계, 많이 쓴 사용자, 최근 실패 목록이 나오고, 칸마다 단가 × 토큰으로 계산한 예상 비용이 붙는다. 날짜는 전부 한국 시간 자정으로 자른다. 서버 시간대로 자르면 아침 9시 전 호출이 전날로 잡힌다.
가장 쓸모 있었던 건 의외로 실패 목록이었다. "응답이 길이 상한에서 끊겼습니다", "응답 시간이 초과됐습니다", "AI 가 요청을 거절했습니다". 사용자 화면에는 "기본 추천으로 채웠어요" 한 줄만 보이지만, 관리자 화면에는 왜 그렇게 됐는지가 남는다.
같은 이유로 AI 결과 신고도 손봤다. 처음에는 신고가 앱 로그에 경고 한 줄로만 남았고, 로그는 찾아보지 않으면 아무도 모른다. 그래서 신고가 들어오면 신고자·사유·관련 레시피 이름을 관리자 알림으로 바로 보내게 했다. 알림 전송이 실패해도 신고 자체는 접수되도록, 알림 부분만 따로 예외를 잡았다.
사람 수와 상관없는 천장
숫자가 보이기 시작하자 상한을 정할 수 있었다. 처음 붙일 때부터 1인 하루 횟수 제한은 있었다. 그런데 그건 사람 수에 비례하는 상한이다. 사용자가 늘면 상한도 같이 늘어난다. 수익이 없는 서비스에 필요한 건 사용자 수와 무관한 천장이었다.
그래서 서비스 전체에 월 예산을 뒀다. 이번 달(한국 시간 1일 0시부터) 쌓인 토큰으로 예상 비용을 계산해서, 그게 예산에 닿으면 그달은 언어 모델을 아예 부르지 않는다. 판정은 호출 전에 한다. 그러니 마지막 한 번만큼은 예산을 살짝 넘을 수 있는데, 호출 중간에 끊는 것보다 그 편이 낫다고 봤다.
예산을 넣으면서 1인 한도도 하루 몇 번에서 한 달 몇 번으로 더 조였다. 비용을 두 겹으로 막겠다는 생각이었다. 그런데 다시 보니 천장은 이미 예산이 맡고 있었다. 1인 한도는 한 사람이 그달 예산을 혼자 다 쓰지 못하게 하는 장치로 충분했다. 그러면 월 몇 번보다 하루 몇 번이 맞다. 월 한도는 초에 다 쓰면 한 달 내내 기다려야 하지만, 하루 한도는 내일 다시 쓸 수 있다. 그래서 하루 기준으로 되돌렸다. 설정은 둘 다 받을 수 있게 하고, 둘 다 주면 더 빠듯한 쪽이 걸리게 했다.
if (monthlyLimit > 0)
quota = new AiQuota(Math.Max(0, monthlyLimit - monthUsed), AiQuota.ThisMonth, monthlyLimit);
if (dailyLimit > 0)
{
var daily = Math.Max(0, dailyLimit - todayUsed);
// 더 빠듯한 쪽. 같으면 하루 쪽 — 곧 풀린다
if (quota.Remaining is null || daily <= quota.Remaining)
quota = new AiQuota(daily, AiQuota.Today, dailyLimit);
}
남은 횟수가 같을 때 하루 쪽을 고르는 건 안내 문구 때문이다. "내일 다시 쓸 수 있어요"와 "다음 달 1일에 다시 쓸 수 있어요" 중 사실이면서 더 가까운 쪽을 보여 주고 싶었다. 횟수는 성공한 호출만 센다.
한도에 걸렸을 때 버튼을 막지는 않았다. 횟수를 다 썼거나 예산이 바닥나면 언어 모델 대신 원래 있던 규칙 기반 추천으로 같은 칸을 채우고, 왜 그랬는지 한 줄로 알려 준다. 규칙 기반은 서버 안에서 끝나니 비용이 없다. 프롬프트에 넣는 후보 레시피 수도 절반으로 줄였다. 후보 목록이 길수록 입력 토큰이 그대로 늘어난다.
공급자를 바꾸는 자리
처음에는 OpenAI 의 Chat Completions 를 HttpClient 로 직접 불렀다. 그래도 기능 코드가 OpenAI 를 직접 알게 하지는 않았다. 식단 짜기, 레시피 정리 같은 기능은 아래 인터페이스 하나만 본다.
public interface ILlmClient
{
Task<LlmJsonResult> CompleteJsonAsync(LlmJsonRequest request, CancellationToken ct = default);
}
public sealed record LlmJsonRequest(string SystemPrompt, string UserPrompt,
string SchemaName, JsonObject Schema, bool Think = true);
public sealed record LlmJsonResult(string Json, string Model, int InputTokens, int OutputTokens);
들어가는 건 프롬프트와 JSON 스키마, 나오는 건 그 스키마를 따르는 JSON 과 토큰 수다. 두 업체 모두 스키마를 강제하는 구조화 출력이 있으니 어느 쪽이든 끼울 수 있다.
두 번째 공급자로 Anthropic 의 Claude 를 붙일 때는 공식 C# SDK 를 썼다. Messages API 에 output_config 로 JSON 스키마를 주고, effort 로 생각의 깊이를 정한다. 구현하면서 두 업체의 실패 모양이 다르다는 걸 알았다. OpenAI 는 거절을 응답 메시지의 refusal 필드로 알려 준다. Claude 는 HTTP 200 에 stop_reason 이 refusal 로 온다. 상태 코드만 보면 성공이다. 그래서 본문을 읽기 전에 stop_reason 부터 확인하고, max_tokens 로 끊긴 응답도 같은 자리에서 실패로 돌렸다. 바깥에서 보면 둘 다 같은 LlmException 이다.
같은 조건으로 비교해 봤다. 2주 식단, 레시피 정리, 레시피 바꾸기를 기존 기본 모델 gpt-5.4-mini 와 claude-haiku-5-5(effort low·medium), Sonnet 으로 돌렸다. Haiku low 가 식단 구성이 더 자연스러웠고, 한 번 비용은 4분의 1 정도였다. 대신 느렸다. 식단 응답이 12초에서 20초가 됐다. 식단은 한 번 받아 두고 오래 쓰는 결과라 그 정도 기다림은 감수하기로 하고, 기본을 Claude 로 옮겼다. OpenAI 는 지우지 않고 예비로 남겼다. 장애가 나면 넘어갈 곳이다.
재시작 없이 바꾸려면
공급자를 고르는 값은 처음에 appsettings 의 Ai:Provider 였다. 바꾸려면 설정 파일을 고치고 앱을 다시 띄워야 한다. 장애 때 넘어가려고 남긴 예비인데 넘어가는 데 재배포가 필요하면 반쪽짜리다. 예산이나 1인 횟수도 운영하면서 몇 번 손대게 될 값이었다.
그래서 관리자 AI 설정 화면을 만들고, 값은 ai_settings 테이블의 한 행(Id = 1)에 저장했다. 칸마다 nullable 이고, 비어 있는 칸은 appsettings·환경변수의 기본값을 그대로 쓴다. 관리자가 건드린 값만 위에 덮는 구조다.
덮어쓴 값을 기능 코드에 전달하는 방법이 고민이었다. 서비스들은 이미 IOptions<AiOptions> 를 받고 있었다. 그 생성자를 전부 바꾸고 싶지 않았다. 그래서 IOptions<AiOptions> 자체를 요청 범위(scoped)로 다시 등록했다.
builder.Services.AddScoped<AiSettingsStore>();
builder.Services.AddScoped<IOptions<AiOptions>>(sp =>
Options.Create(sp.GetRequiredService<AiSettingsStore>().GetEffective()));
builder.Services.AddScoped<ILlmClient>(sp =>
sp.GetRequiredService<IOptions<AiOptions>>().Value.UseAnthropic
? sp.GetRequiredService<ClaudeLlmClient>()
: sp.GetRequiredService<OpenAiLlmClient>());
GetEffective() 는 기본값을 복제한 뒤 관리자 설정 행을 덮어 돌려준다. 행은 메모리 캐시에 1분 두고, 저장할 때 캐시를 바로 비운다. 그러니 저장 버튼을 누른 다음 요청부터 새 값이 적용된다. ILlmClient 도 요청마다 그 값을 보고 구현을 고른다. 공급자를 바꾸면 다음 요청은 다른 업체로 간다.
대가도 있었다. IOptions<AiOptions> 가 scoped 가 되면서, 이걸 받는 서비스는 모두 scoped 여야 한다. 싱글턴이 받으면 앱이 시작할 때 오류가 난다. Claude SDK 클라이언트는 내부에 HttpClient 를 들고 있어서 요청마다 새로 만들면 안 되는데, 서비스는 scoped 다. 그래서 SDK 클라이언트만 키·제한 시간 조합을 열쇠로 정적 사전에 캐시해 재사용했다.
모델을 바꾸면 단가도 바뀐다. 그래서 모델 이름 접두사로 단가를 찾는 내장 표를 두고, 관리자가 단가를 직접 넣지 않으면 그 표를 따르게 했다. 접두사라 날짜 붙은 스냅샷 이름도 같은 단가로 잡힌다. 모델만 바꾸면 예상 비용과 월 예산 판정도 따라온다.
API 키는 이 화면에서 다루지 않는다. 키는 서버 환경변수에만 두고, 화면에는 공급자별로 키가 "있음/없음"만 표시한다. 저장할 때마다 누가 무엇을 무엇으로 바꿨는지를 앱 로그에 경고 수준으로 남겼다. 기능 전체를 끄는 스위치도 같은 화면에 넣었다.
null 하나가 꺼 버린 기능
Claude 로 옮기고 나서 한 달짜리 식단이 출력 한도에서 끊겼다. Haiku 는 생각(thinking)이 기본으로 켜져 있어 생각 토큰이 한참 붙었다. 후보 번호 중에서 고르는 일에는 필요 없는 생각이라, LlmJsonRequest 에 Think 플래그를 넣고 식단만 끄기로 했다. 끄고 나니 10일치 응답이 57초에서 9초로, 출력 토큰이 9.7K 에서 1.9K 로 줄었다.
처음 쓴 코드는 이랬다.
Thinking = request.Think ? null : new ThinkingConfigDisabled(),
끌 때는 비활성 설정을 넣고, 켤 때는 null 로 "기본값 그대로"를 뜻하려 했다. 식단은 멀쩡해졌다. 대신 이번에는 레시피 정리와 바꾸기가 실패하기 시작했다. 생각을 켜 둔 쪽이다.
SDK 의 요청 모델에서 Thinking 에 null 을 대입하는 건 "안 보낸다"가 아니었다. 그 값을 요청 본문으로 바꾸는 변환 과정에 null 이 그대로 들어가 호출이 깨졌다. 빌드 때 이미 nullable 경고(CS8604)가 그 줄을 가리키고 있었다. 경고를 그냥 지나친 대가였다.
var p = new MessageCreateParams { /* 모델·스키마·메시지 */ };
// 켤 때는 Thinking 을 아예 넣지 않는다
if (!request.Think) p = p with { Thinking = new ThinkingConfigDisabled() };
res = await _client.Messages.Create(p, cancellationToken: ct);
고친 뒤로는 끌 때만 속성을 넣는다. 레시피 붙여넣기 정리가 4초 만에 정상으로 돌아왔다. "기본값으로 둔다"를 null 로 표현할 수 있는지는 SDK 가 정한다. C# 객체 초기화에서는 대입하지 않는 것과 null 을 대입하는 것이 다르다. 그 차이를 경고가 알려 주고 있었다.
밖으로 나가는 것들
공급자를 고를 수 있게 만든 순간, 사용자 데이터가 어디로 가는지를 말하는 문서도 같이 바뀌어야 했다.
개인정보 처리방침의 처리 위탁 항목에 현재 쓰는 Anthropic, PBC(미국)와 예비 OpenAI, L.L.C.(미국)를 함께 적고, 기능별로 무엇이 넘어가는지 나눠 썼다. 두 업체 모두 API 로 받은 데이터를 학습에 쓰지 않고 오남용 감시를 위해 최대 30일 보관한다는 점도 적었다. 뺄 재료는 서버가 후보 단계에서 거르기 때문에 업체로 보내지 않는다. 알레르기를 따로 묻지 않고 "뺄 재료"로만 받는 것도 건강 정보를 모으지 않으려는 선택이었다.
화면 안내 문구는 업체 이름 대신 "AI 서버(미국)"로 썼다. 공급자를 바꿔도 틀리지 않는 문장이어야 했다. Play 데이터 보안 답변은 우리 대신 처리하는 서비스 제공업체 전송이라 "공유"에는 해당하지 않는다는 근거를 문서에 보강했다.
나중에 홈에 "AI 오늘의 한 상"을 붙이면서 한 문장을 다시 고쳐야 했다. 처리방침에는 "AI 기능을 쓰지 않으면 아무것도 보내지 않는다"고 적어 뒀는데, 오늘의 한 상은 홈을 열 때 하루 한 번 자동으로 부른다. 사용자가 누르지 않아도 나가는 첫 번째 전송이었다. 문장을 사실에 맞게 바로잡고, 끌 수 있는 길을 만들었다. 홈 칸의 "끄기"를 누르거나 알림 종 메뉴에서 체크를 풀면 칸이 사라지고, 서버도 그 사용자에게는 언어 모델을 부르지 않는다.
다음 편은 냉장고에 있는 재료로 레시피를 고르는 추천과, 그 추천이 한 끼 구성을 지키게 만든 규칙 이야기다.