Claude API 직접 연동으로 AI 상담 품질과 속도 개선
목차
외부 AI 서비스 래퍼를 걷어내고 Claude API를 직접 붙인 작업이었는데, 규모 대비 변화가 꽤 컸다.
중간 레이어가 하나 있다는 건 단순히 레이턴시 문제만이 아님. 외부 래퍼가 끊기면 우리 서비스도 같이 끊기고, 래퍼의 업데이트 정책에 끌려다니고, 프롬프트 커스터마이징도 래퍼가 허용하는 범위 안에서만 해야 했다. 직접 연동으로 바꾸고 나서 이 세 가지가 전부 우리 통제권 안으로 들어왔다. 특히 프롬프트 자유도는 체감이 확실함. 시스템 프롬프트를 원하는 대로 구성하고, 역할 경계를 명시하고, FAQ를 그대로 인라인으로 넣을 수 있게 됐다.
기능 구조는 최대한 단순하게 유지했다:
사용자 질문
→ 내부 클래스 (API 호출)
→ Claude API (messages endpoint)
→ 응답 파싱
→ 채팅 UI 렌더링
레이어를 최소화하면 디버깅이 쉬워진다. 뭔가 이상하면 내부 클래스 로그 찍으면 되고, 외부 래퍼 시절엔 "래퍼 측 문제인지, 우리 쪽 문제인지" 분류하는 시간이 항상 필요했다. 장애 대응 시간이 짧아지는 것도 결국 이 단순함 덕분이다.
시스템 프롬프트 설계와 FAQ 주입
AI 상담 품질은 결국 시스템 프롬프트에서 갈린다. 모델이 아무리 좋아도 프롬프트가 엉성하면 엉뚱한 말이 나온다. 이번에 정리한 것들:
- 상담사 이름과 역할 소개 문구: 처음 접속한 사용자가 "이게 뭐 하는 AI인지" 즉각 알아야 어색함 없이 질문을 시작한다. 안내 문구 없이 빈 입력창만 뜨면 뭘 물어야 할지 막막해함
- FAQ 목록 주입: DB나 파일에서 로드한 FAQ를 시스템 프롬프트에 인라인으로 넣어서 자주 묻는 질문엔 일관된 답이 나오게 함
- 역할 범위 명시: 상담사가 뭘 답할 수 있고 뭘 못 하는지 프롬프트에 명확히 적어두면 할루시네이션이 줄어들고, 범위 밖 질문이 들어왔을 때 모델이 스스로 처리해준다
FAQ 주입 방식엔 트레이드오프가 있다. 프롬프트가 길어질수록 토큰 비용이 올라가고, 너무 많은 FAQ를 때려넣으면 모델이 관련 없는 FAQ를 "참조"해서 엉뚱한 맥락으로 답하는 경우가 생긴다. 지금은 카테고리별로 묶어서 필요한 것만 주입하는 방향이고, 나중엔 유사도 검색으로 관련 FAQ만 추출해서 넣는 구조로 바꾸는 걸 고려 중이다. FAQ 수가 늘어날수록 전체 주입은 확장이 안 되기 때문임.
보안 처리는 이번에 같이 붙였다:
| 항목 | 처리 방식 |
|---|---|
| 접근 제어 | 로그인 필수, 비로그인 차단 |
| 부적절 이용 감지 | 경고 후 30분 정지 |
| 상태 저장 | 경고/정지 횟수 DB 기록 |
경고/정지 횟수를 DB에 저장하는 건 세션이 끊겨도 상태가 유지되게 하기 위해서임. 메모리에만 들고 있으면 서버 재시작이나 스케일아웃 상황에서 초기화된다. 어드민 입장에서도 "이 사용자가 몇 번 경고를 받았는지" 기록이 남아야 나중에 패턴을 보고 정책을 조정할 수 있다.
30분 정지 기준은 어느 정도 임의적임. 너무 짧으면 억제 효과가 없고 너무 길면 실수로 경고를 받은 정상 사용자가 피해를 본다. 처음엔 짧게 잡고 같은 사용자가 반복 경고를 받으면 정지 시간을 지수적으로 늘리는 방향이 일반적으로 쓰이는 패턴이다. 지금 당장 구현하지 않더라도, DB에 횟수를 남겨두는 건 나중에 이 방향으로 가기 위한 준비이기도 하다.
Rate Limit - 정상 사용자를 기준점으로
Rate Limit을 설계할 때 흔한 실수는 "얼마나 많이 막을 것인가"를 기준으로 숫자를 잡는 것이다. 기준은 반대여야 함 - 정상 사용자가 일반적으로 얼마나 쓰는지를 먼저 파악하고, 그걸 넘으면서 공격성이 있는 패턴을 막는 것. 너무 타이트하게 잡으면 정상 사용자가 피해를 보고, 너무 느슨하면 아무 의미가 없다.
고정 윈도우 방식은 구현이 단순하지만 경계 구간 버스팅 문제가 있다. 1분 단위 고정 윈도우라면 00:59에 최대치를 다 쓰고 01:00에 리셋되자마자 또 최대치를 쓰면 2초 안에 두 배를 보낼 수 있음. Redis 슬라이딩 윈도우는 이 버스팅을 막아준다. 어느 시점에서 봐도 최근 N초 동안의 요청 수가 제한 이내로 유지된다.
다만 Redis를 넣으면 반드시 같이 챙겨야 하는 게 있다:
# Redis 연결 실패 시 폴백 처리 (의사코드)
try:
key = f"rate:{user_id}"
count = redis.incr(key)
redis.expire(key, window_seconds)
if count > limit:
raise RateLimitExceeded()
except RedisConnectionError:
# AI API처럼 외부 비용이 붙는 엔드포인트 → Redis 장애 시에도 차단
# 일반 읽기 요청이라면 통과시키는 게 UX 관점에서 나을 수 있음
raise RateLimitExceeded()
Redis가 단일 장애점이 되는 건 연결 풀과 타임아웃 설정으로 어느 정도 완화할 수 있지만, Redis 자체가 내려가면 rate limit 로직이 터진다. 이때 정책 선택이 필요한데, AI API처럼 외부 비용이 발생하는 경로에서 rate limit이 우회되면 그 자체가 장애 상황이 될 수 있다. 연결 풀 설정과 짧은 타임아웃(200-300ms 수준)으로 Redis 장애를 빠르게 감지하고, 감지되면 기본적으로 차단하는 게 이런 엔드포인트엔 더 안전한 선택이다.
이번 작업은 규모가 크지 않았지만, 각 항목이 독립적으로 존재하지 않는다는 게 핵심이었다. 직접 API 연동, 시스템 프롬프트 정리, 접근 제어, Rate Limit - 어느 하나가 빠지면 나머지가 제대로 돌아도 운영 안정성에 구멍이 생긴다. 작은 수정들이 맞물려서 전체 시스템이 버티는 구조가 되는 건 이런 식으로 쌓인다.
댓글 0
첫 댓글 달아줘.