Mistral AI를 사용하여 Rails API에서 AI 기반 꿈 분석 기능 구축하기
요약
Mistral AI를 활용하여 Rails API 기반의 꿈 분석 기능을 구현하는 과정을 다룹니다. 비동기 백그라운드 작업, 상태 관리(enum), 데이터 암호화 등 프로덕션 환경에 적합한 안정적인 아키텍처 설계 방법을 소개합니다.
핵심 포인트
- Mistral AI를 이용한 구조화된 꿈 분석 구현
- Rails 비동기 패턴과 백그라운드 작업 활용
- Enum을 이용한 분석 라이프사이클 상태 관리
- Active Record Encryption을 통한 개인정보 보호
Pastel은 제가 혼자 만든 프로젝트입니다. 사용자가 꿈, 악몽, 자각몽(lucid sleeps), 그리고 그와 함께 나타나는 감정 패턴 등 자신의 수면 경험을 기록하는 API입니다. 최근 저는 이 프로젝트를 한 단계 더 발전시키기로 했습니다. 만약 앱이 사용자의 꿈을 분석해 줄 수 있다면 어떨까요? 설명, 기분, 태그를 살펴보고 사용자의 꿈이 무엇을 말하고 있는지 의미 있는 정보를 제공할 수 있다면 어떨까요?
그래서 저는 이를 구현했습니다. Mistral AI, 백그라운드 작업(background jobs), 그리고 수많은 방어적 코딩(defensive coding)을 사용하여 말이죠. 제가 어떻게 구현했는지 소개하겠습니다.
내가 원했던 것
제가 구상한 흐름은 간단했습니다. 사용자가 제목, 설명, 수면 유형(자각몽, 악몽, 반복되는 꿈), 태그, 현재 기분, 그리고 경험의 강도와 함께 수면 기록을 남기는 것입니다. 이 모든 문맥(context)으로부터 저는 **구조화되고 통찰력 있는 해석(structured, insightful interpretation)**을 반환하고 싶었습니다. 그리고 단순히 영어뿐만 아니라 사용자의 언어로 말이죠.
어려운 점은 API 호출 자체가 아니었습니다. 진짜 어려운 점은 이를 신뢰할 수 있고, 테스트 가능하며, 프로덕션 환경에 적합하도록(production-ready) 만드는 것이었습니다.
내가 결정한 아키텍처
POST /api/v1/sleeps/:id/analyse
│
▼
...
이는 전형적인 Rails 비동기(async) 패턴입니다. 컨트롤러(controller)가 유효성을 검사하고 작업을 큐에 넣으면(enqueues), 백그라운드 작업이 이를 조율하고, 서비스 객체(service object)가 비즈니스 로직을 캡슐화합니다. 각 레이어를 하나씩 살펴보겠습니다.
첫 번째 문제: 분석 라이프사이클(lifecycle)을 어떻게 추적할 것인가?
꿈 분석은 즉각적으로 이루어지지 않습니다. 저는 각 요청이 라이프사이클의 어느 단계에 있는지 추적할 방법이 필요했습니다. 단순히 분석 여부를 나타내는 불리언(boolean) 값을 사용할 수도 있었지만, 그것은 불안정해 보였습니다. 만약 API 타임아웃이 발생한다면 어떡하죠? 작업(job)이 충돌한다면요? 사용자가 엔드포인트(endpoint)를 스팸처럼 계속 호출한다면 어떻게 될까요?
그래서 저는 Sleep 모델에 적절한 3단계 열거형(three-state enum)을 추가했습니다:
ANALYSIS_STATUS = {
not_started: 'not_started',
in_progress: 'in_progress',
...
그리고 실수로 어떤 일이 일어나는 것을 방지하기 위해 두 개의 명시적인 상태 전이(state-transition) 메서드를 만들었습니다:
def mark_as_analysis_not_started
update(analysis_status: :not_started)
end
...
또한 다른 민감한 데이터(title, description, current_mood)와 함께 analysis 필드도 암호화했습니다. 꿈 분석 결과는 매우 개인적인 정보이므로, 원래의 꿈과 동일한 수준의 보호를 받아야 합니다:
encrypts :title, :description, :current_mood, :analysis
이 기능에 대한 자세한 내용은 Rails Active Record Encryption 문서를 참조하세요.
마지막으로, 얼마나 많은 꿈이 분석되었는지 추적할 수 있도록 대시보드용 스코프(scope)를 추가했습니다:
scope :ai_analyzed, -> { where.not(analysis: nil).where(analysis_status: :done) }
컨트롤러(Controller): 멱등성(Idempotency)에 대한 집착
#analyse 액션을 작성할 때 가장 먼저 생각한 것은 '누군가 이 액션을 두 번 호출하면 어떻게 될까?'였습니다. 혹은 모바일 앱이 느린 연결 때문에 요청을 재시도한다면 어떨까요?
def analyse
if @sleep.analysis_status == 'done' ||
@sleep.analysis_status == 'in_progress' ||
...
여기서 제가 자랑스럽게 생각하는 세 가지 결정 사항이 있습니다:
- 어디에서나 멱등성(Idempotency) 유지. 상태 열거형(status enum)과 기존 분석 데이터의 존재 여부를 모두 확인합니다. 엔드포인트가 백 번 호출되더라도, 오직 첫 번째 호출만 실제로 동작합니다.
- 로케일(Locale) 전달. 로케일 정보가 요청(request)에서 작업(job)을 거쳐 서비스(service)로 전달되고, Mistral 프롬프트에 직접 입력됩니다. 이를 통해 AI는 사용자의 언어로 응답합니다.
- 낙관적 상태 업데이트(Optimistic status update). 상태를 즉시
in_progress로 설정합니다. 작업이 실행되는 동안 두 번째 요청이 도착하면 깔끔하게 거부됩니다.
백그라운드 처리를 위한 Solid Queue
꿈 분석을 비동기적으로 처리하기 위해 Rails의 내장 Active Job 백엔드인 Solid Queue를 통합했습니다. 컨트롤러 요청 내에서 AI API 호출을 동기식(synchronously)으로 수행하는 것은 최악의 사용자 경험을 초래합니다.
class SleepAnalyseJob < ApplicationJob
queue_as :sleep_analysis
...
이 작업(job)에는 세 가지 방어 계층이 있습니다:
find대신find_by사용. 컨트롤러와 작업(job) 실행 사이에 수면 데이터(sleep)가 삭제된 경우, 크래시(crash)가 발생하는 대신nil을 반환받습니다.- 멱등성 (Idempotency) 재확인. 경합 조건 (Race conditions)은 실제로 발생합니다. 컨트롤러가 상태를
in_progress로 설정했지만, 서비스를 호출하기 전에 다시 한번 확인합니다. - 우아한 복구 (Graceful recovery). API 다운, 네트워크 문제 등 무엇인가가 폭발하더라도(explodes), 상태를
not_started로 재설정하여 사용자가 다시 시도할 수 있도록 합니다. 그 후 에러를 다시 발생시켜(re-raise) 작업 프레임워크가 재시도 로직을 처리하도록 합니다.
또한 이를 전용 :sleep_analysis 큐(queue)에 배치했습니다. 앱이 성장하고 다른 백그라운드 작업이 생기더라도, 큐별로 동시성 (concurrency)과 우선순위를 제어할 수 있습니다. 구성에 대한 자세한 내용은 Solid Queue 문서를 확인하세요.
서비스: Mistral과의 대화
이 부분은 제가 가장 기대하면서도 가장 긴장했던 부분입니다. 저는 다른 젬 (gem)을 가져오는 대신 Ruby의 표준 라이브러리 (stdlib)에 있는 Net::HTTP를 사용하기로 결정했습니다. 단일 API 연동을 위해 Faraday나 HTTParty와 같은 의존성 오버헤드 (dependency overhead)를 원하지 않았기 때문입니다.
class SleepAnalysisService < ApplicationService
def initialize(sleep, locale)
super()
...
단순한 진입점: 전송, 파싱, 영속화 (persist). 각 단계는 이전 단계를 확인합니다.
왜 Mistral이며 어떤 모델인가
저는 Mistral의 magistral-small-2509 모델을 선택했습니다. 이 모델은 빠르고 비용 효율적이며, 창의적이고 해석적인 작업에 대해 양질의 응답을 생성합니다. 페이로드 (payload)는 그들의 chat completions API를 따릅니다:
def build_payload
{
model: 'magistral-small-2509',
...
엔드포인트, 인증 등에 대한 전체 Mistral API 문서를 확인해 보세요.
가장 어려운 부분: 프롬프트 엔지니어링 (Prompt Engineering)
솔직히 말씀드리면, 저는 시스템 프롬프트 (System Prompt)를 적어도 다섯 번은 다시 작성했습니다. 첫 번째 버전은 응답이 너무 일반적이었습니다. 두 번째 버전은 너무 임상적이었습니다. 저는 균형을 찾아야 했습니다. 신비주의에 빠지지 않으면서 통찰력이 있고, 주제넘지 않으면서도 개인적인 느낌을 주는 균형 말입니다.
결과적으로 제가 도달한 결과물은 다음과 같습니다:
def system_prompt
<<~PROMPT
You are an expert dream analyst combining psychology, symbolism, and emotional intelligence.
...
이 과정을 통해 배운 점은 다음과 같습니다:
- 구조가 전부입니다. 정확한 마크다운 (Markdown) 헤더를 지정함으로써, 프론트엔드 (Frontend)에서 섹션을 일관되게 파싱 (Parse)하고 표시할 수 있게 되었습니다. 구조가 없으면 응답은 그저 텍스트의 벽처럼 보였습니다.
- 프롬프트 내의 로케일 (Locale). 저는 로케일을 시스템 프롬프트에 직접 보간 (Interpolate)합니다. Mistral은 다국어 출력을 훌륭하게 처리하므로, 저는 어떤 언어를 사용할지만 알려주면 됩니다.
- 톤 가드레일 (Tone Guardrails). "지나치게 신비주의적이거나 임상적이지 않도록 주의하라"는 문구는 정말 이상한 응답을 몇 번 받은 후에 추가되었습니다. 때때로 AI는 완전히 점술가처럼 행동하기도 하고, 때로는 의료 보고서처럼 읽히기도 합니다. 이 제약 조건이 AI를 가장 적절한 지점에 머물게 합니다.
사용자 프롬프트 (User Prompt)는 더 간단합니다. 저는 모든 수면 데이터를 일관된 요청으로 조합하기만 하면 됩니다:
def user_prompt
<<~PROMPT
Please analyze this dream and provide a structured interpretation:
...
저는 제목, 유형, 발생 시점, 전체 설명, 태그, 기분, 그리고 강도(Intensity)와 같은 모든 속성을 전달하도록 합니다. 특히 태그, 기분, 강도가 중요하다는 점을 명시적으로 언급하는데, 이는 AI가 설명에만 집중하고 메타데이터 (Metadata)를 무시하는 경향이 있기 때문입니다.
HTTP 요청 (The HTTP Request)
특별한 것은 없습니다. 적절한 에러 핸들링 (Error Handling)을 갖춘 Net::HTTP를 사용할 뿐입니다:
def send_request_to_mistral
uri = URI(mistral_api_url)
http = Net::HTTP.new(uri.host, uri.port)
...
Ruby에서의 HTTP 요청에 대한 자세한 내용은 Ruby Net::HTTP 문서를 참조하세요.
무언가 잘못된다면 — HTTP 오류, 네트워크 실패, 무엇이든 — 저는 이를 로그로 남기고, 상태를 초기화한 뒤 nil을 반환합니다. 호출자(Caller)는 이 nil을 우아하게 처리합니다.
응답 파싱하기 (Parsing the Response)
def parse_response(raw_response)
response = JSON.parse(raw_response)
return response['choices'].first['message']['content'][1]['text'] if response.present?
...
솔직히 말씀드리면 — 이 파싱 방식은 취약합니다. 텍스트 콘텐츠로 가는 경로는 Mistral의 응답 구조에 의존합니다. 만약 그들이 API를 변경한다면, 이 코드는 깨집니다. 대규모 프로덕션 시스템이라면 dig 메서드나 응답 스키마 검증기(Response schema validator)를 사용할 것입니다. 지금은 작동하며, 만약 깨진다면 정확히 어디를 확인해야 할지 알고 있습니다.
테스트: 예상치 못한 API 비용을 원하지 않기 때문에
AI 통합(Integration)을 테스트하는 것은 까다롭습니다. 테스트를 실행할 때마다 실제 Mistral API를 호출하고 싶지는 않습니다. 비용이 들고, 느리며, 불안정하기 때문입니다. 하지만 제 코드가 실제 응답을 올바르게 처리한다는 확신은 필요합니다.
VCR이 구원해 줄 것입니다
저는 실제 API 응답을 기록하고 이를 결정론적(Deterministic)으로 재생하기 위해 VCR을 설정했습니다:
# spec/support/vcr.rb
VCR.configure do |config|
config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
...
filter_sensitive_data 라인이 매우 중요합니다. 이 설정은 카세트(Cassette) 파일 내의 실제 API 키를 대체하여, 실수로 키를 커밋하는 일을 방지합니다. WebMock과 결합하여, VCR은 테스트 중 발생하는 모든 HTTP 요청을 가로챕니다.
그다음 서비스 스펙(Service spec)에서:
describe SleepAnalysisService do
let(:sleep) { create(:sleep, :with_tags) }
let(:locale) { 'en' }
...
이것을 처음 실행할 때, VCR은 실제 Mistral API를 호출하고 응답을 기록합니다. 그 이후부터는 매번 카세트를 재생합니다. 빠르고, 결정론적이며, 무료입니다.
작업(Job) 테스트하기
작업 스펙(Job spec)은 멱등성(Idempotency)과 오류 복구(Error recovery)를 검증합니다:
describe SleepAnalyseJob do
let(:sleep) { create(:sleep) }
...
엔드포인트(Endpoint) 테스트하기
그리고 요청 레벨(Request level)에서는, 멱등성 가드(Idempotency guard)가 실제로 작동하는지 확인합니다:
describe 'POST /api/v1/sleeps/:id/analyse' do
context '분석이 이미 완료된 경우' do
before { sleep.mark_as_analysis_done('analysis text') }
...
진행 과정에서 배운 점
프롬프트 엔지니어링 (Prompt Engineering)은 반복적인 과정이다
이 점은 아무리 강조해도 지나치지 않습니다. 시스템 프롬프트 (System prompt)는 최소 다섯 번의 수정을 거쳤습니다. 수정할 때마다 개선되었지만, 저는 실제로 AI의 출력물 (Outputs)을 읽고 패턴을 파악해야 했습니다. 너무 모호하거나, 너무 임상적이거나, 태그를 무시하거나, 잘못된 언어를 사용하는 등의 패턴 말입니다. 실제 출력물을 바탕으로 반복 (Iterating) 작업을 거친 후에야 비로소 적절하다고 느껴지는 결과물을 얻을 수 있었습니다.
상태 머신 (Status Machines)은 스스로의 실수를 방지해 준다
세 가지 상태의 열거형 (Enum)과 컨트롤러 (Controller) 및 작업 (Job) 레벨 모두에서의 이중 확인 방식은, 제가 인지조차 못 했던 버그들로부터 저를 구해 주었습니다. 경합 조건 (Race conditions), 재시도 (Retries), 중복 요청 (Duplicate requests) 등은 제가 사전에 실패 모드 (Failure modes)를 고려했기 때문에 모두 우아하게 처리되었습니다.
VCR은 설정할 만한 가치가 있다
API 응답을 한 번 기록하고 영원히 재현하는 것은 초능력과 같습니다. 덕분에 테스트 스위트 (Test suite)가 빠르게 실행되고, API 속도 제한 (Rate limits)을 걱정할 필요가 없으며, 매번 CI를 실행할 때마다 크레딧 (Credits)을 낭비하지 않아도 됩니다.
실패 시에는 항상 초기화하라
개발 초기에는 API 호출이 실패했을 때 상태가 in_progress에 멈춰버리는 버그가 있었습니다. 사용자는 재시도할 수 없었고, 분석 데이터는 고아 (Orphaned) 상태가 되었습니다. 그때 모든 에러 경로 (Error path)에 mark_as_analysis_not_started 호출을 추가했습니다. 방어적 코딩 (Defensive coding)은 편집증이 아니라 경험입니다.
의존성 (Dependencies)은 적을수록 좋다
Faraday나 HTTParty, 또는 전용 Mistral SDK를 추가할 수도 있었습니다. 대신 저는 Net::HTTP를 사용했고, 이는 잘 작동합니다. 코드가 조금 더 장황해질 수는 있지만, 유지 관리해야 할 젬 (Gem)이 하나 줄어들고 감사 (Audit)해야 할 의존성도 하나 줄어듭니다.
마치며
이 기능을 구축하는 것은 본질적으로 예측 불가능한 것 — 즉 AI API 호출 — 을 예측 가능하고 잘 테스트된 인프라 (Infrastructure)로 감싸는 훌륭한 연습이었습니다. Mistral 통합 자체는 전체 작업의 약 30% 정도에 불과합니다. 나머지 70%는 상태 추적 (Status tracking), 백그라운드 작업 (Background jobs), 에러 복구 (Error recovery), 멱등성 (Idempotency), 그리고 테스트입니다.
그 부분이 제가 가장 자랑스럽게 생각하는 부분입니다. 단순히 AI API를 호출했다는 점이 아니라, 제가 일일이 지켜보지 않아도 프로덕션 (Production) 환경에서 실행될 수 있을 만큼 충분히 탄력적인 (Resilient) 시스템을 구축했다는 점입니다.
만약 여러분도 비슷한 것을 만들고 있다면, 제 조언은 이렇습니다. 프롬프트 (Prompt)에 공을 들이는 것보다 배관 작업 (Plumbing)에 더 많은 시간을 투자하세요. 에러 처리 (Error handling)가 엉망인 좋은 프롬프트는 소리 없이 실패할 것입니다. 반면, 에러 처리가 훌륭한 평범한 프롬프트는 최소한 우아하게 실패할 것이며, 여러분은 나중에 프롬프트를 수정할 수 있는 로그 (Logs)를 확보하게 될 것입니다.
전체 구현 내용은 GitHub의 Pull Request에서 확인하실 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기