AI 상담 부적절 이용 경고·정지 기능 도입
목차
외부 SaaS를 래핑하던 AI 상담 기능을 Claude API 직접 호출 방식으로 교체하면서, 부적절 이용 경고-정지 로직까지 함께 붙인 작업이다. 규모는 크지 않았는데 손댈 곳이 생각보다 많았음.
왜 직접 호출로 바꿨나
외부 서비스 래핑 방식은 초기엔 편하다. 설정 몇 줄이면 챗 UI가 뜨고, 인증도 대신 처리해준다. 하지만 쓰다 보면 구멍이 보인다. 시스템 프롬프트 커스터마이징 범위가 좁고, 응답 포맷을 마음대로 뜯기 어렵고, 레이턴시가 래퍼 서버를 한 번 더 거치는 만큼 올라간다. 특히 FAQ 주입처럼 "프롬프트를 서비스 데이터와 결합"해야 하는 요구가 생기면 외부 래퍼는 발목을 잡는다.
직접 호출로 전환하면 messages endpoint를 그대로 쓰기 때문에 system 파라미터에 원하는 컨텍스트를 자유롭게 밀어 넣을 수 있다. 구조는 단순하게 유지했음.
사용자 질문
→ 내부 클래스 (API 호출)
→ Claude API (messages endpoint)
→ 응답 파싱
→ 채팅 UI 렌더링
중간 레이어가 사라지니 레이턴시가 체감상 줄었고, 로그도 직접 찍을 수 있어서 디버깅이 편해졌다.
부적절 이용 감지와 경고-정지 설계
AI 상담 기능을 공개하면 반드시 테스트 삼아, 혹은 의도적으로 욕설-도발-무관한 요청을 쏟아내는 케이스가 나온다. 외부 서비스를 쓸 때는 그쪽 정책에 의존했지만, 직접 호출로 전환하면 이 부분을 직접 책임져야 한다.
구현 방향은 세 단계로 정리했음.
- 로그인 필수 처리 - 비로그인 상태에서 상담 엔드포인트에 접근하면 막음. 익명 사용은 행위 추적이 불가능하다.
- 경고 후 30분 정지 - 기준을 초과하면 즉시 차단하지 않고 먼저 경고를 띄운다. 다음 위반 시 30분 정지.
- 경고/정지 횟수 DB 저장 - 메모리나 세션에 두면 서버 재시작 시 초기화된다. 영속 저장이 필요했음.
"바로 차단"보다 "경고 먼저" 방식을 택한 건 오탐 부담 때문이다. 악의 없는 표현이 필터에 걸렸을 때 사용자가 피드백 없이 차단되면 혼란이 크다. 경고 단계를 두면 사용자는 무슨 일이 일어났는지 알 수 있고, 운영 측에서도 오탐 여부를 확인할 시간이 생긴다.
정지 기간을 30분으로 잡은 건 임의적이지만 나름의 근거는 있다. 너무 짧으면 재시도 비용이 없어서 억제 효과가 없고, 너무 길면 실수한 사용자가 완전히 이탈한다. 상담 서비스 특성상 영구 정지는 처음부터 걸지 않는 게 맞다고 봤음.
# 경고/정지 체크 흐름 (의사 코드)
def check_abuse(user_id, message):
record = db.get_abuse_record(user_id)
if record.is_suspended():
remaining = record.suspension_ends_at - now()
raise SuspendedError(remaining_minutes=remaining)
if is_abusive(message):
record.warn_count += 1
if record.warn_count >= WARN_THRESHOLD:
record.suspend(duration_minutes=30)
db.save(record)
raise WarnedError(warn_count=record.warn_count)
is_abusive 판단 로직을 어디 두느냐가 핵심인데, 간단한 키워드 필터로 시작해서 Claude 자체에 판단을 위임하는 방식으로 단계적으로 고도화하는 게 현실적이다. 처음부터 AI 기반 판단을 넣으면 모든 메시지에 추가 API 호출이 붙어서 레이턴시와 비용이 배로 오른다.
FAQ 주입과 시스템 프롬프트 설계
시스템 프롬프트에 FAQ를 박아서 자주 묻는 질문에 일관된 답변이 나오게 했다. 구조는 이렇게 됨.
- DB 또는 파일에서 FAQ 로드
- 시스템 프롬프트 템플릿에 주입
- messages API 호출 시 system 파라미터로 전달
FAQ를 하드코딩하지 않고 DB/파일에서 로드하는 이유는 운영 중 수정이 필요할 때 배포 없이 반영하기 위해서다. 상담 내용은 서비스 상태에 따라 바뀌는데 그때마다 코드를 건드리면 관리 비용이 올라간다.
주의할 점은 FAQ가 길어질수록 컨텍스트 윈도우를 압박한다는 것이다. FAQ가 수십 개를 넘어가면 전부 주입하기보다는 질문 임베딩 유사도로 관련 항목만 골라 주입하는 RAG 방식이 낫다. 지금은 FAQ 수가 많지 않아서 전체 주입으로도 문제없지만, 콘텐츠가 늘어나면 그쪽으로 전환을 고려해야 한다.
AI 상담사 이름과 역할 소개 문구도 이 시점에 정리했음. 처음 접속했을 때 안내 문구가 없으면 사용자는 뭘 물어야 할지 모른다. 인터페이스가 아무리 깔끔해도 "여기서 어떤 걸 도와줄 수 있는지"를 첫 화면에 명시하지 않으면 이탈률이 올라간다. 간단한 것 같지만 실제로 사용성에 영향이 컸음.
운영 관점에서 본 Rate Limit
Rate limit을 설계할 때 "얼마나 많이 막을 것인가"에 집중하기 쉬운데, 실제로 더 중요한 건 "정상 사용자를 막지 않으면서 공격을 차단하는가"다.
고정 윈도우(Fixed Window) 방식은 구현이 단순하지만 경계 구간 버스팅에 약하다. 윈도우 종료 직전에 100회, 시작 직후에 100회를 몰아치면 짧은 시간에 200회가 통과된다. 슬라이딩 윈도우(Sliding Window)는 이 문제를 해결하지만 카운터를 시간 축으로 관리해야 해서 구현이 조금 더 복잡하다.
| 방식 | 버스팅 대응 | 구현 복잡도 | 메모리 사용 |
|---|---|---|---|
| 고정 윈도우 | 약함 | 낮음 | 낮음 |
| 슬라이딩 윈도우 | 강함 | 중간 | 중간-높음 |
| 토큰 버킷 | 유연 | 높음 | 중간 |
Redis 슬라이딩 윈도우를 쓸 때 한 가지 더 챙겨야 할 게 있다. Redis가 단일 장애점이 된다. Redis 다운 시 rate limit 체크 자체가 불가능해지는데, 이때 "허용(fail-open)"으로 갈지 "차단(fail-closed)"으로 갈지 정책을 미리 정해야 한다. 상담 서비스라면 Redis 장애 시에도 사용자가 접근할 수 있어야 하니 fail-open이 맞다. 단, 연결 타임아웃을 짧게 잡아서 Redis 장애가 응답 지연으로 번지지 않도록 해야 한다.
연결 풀 설정도 같이 챙겼음. 기본값 그대로 두면 트래픽 스파이크 때 연결 고갈이 일어난다. rate limit 레이어 자체가 병목이 되면 본말이 전도되는 상황이 생기니, 설정값을 실제 트래픽 패턴에 맞춰 조정하는 게 필요하다.
작업 규모는 크지 않았지만 AI 기능을 직접 운영하기 위해 필요한 기반들을 정리한 시간이었음. 경고-정지 로직, 프롬프트 설계, rate limit 정책 같은 것들은 처음 만들 때 구조를 제대로 잡아두지 않으면 나중에 수정 비용이 커진다. 그 작은 수정들이 쌓여서 전체 시스템의 신뢰도를 만든다.
댓글 0
첫 댓글 달아줘.