1편에서 만든 감정 분류기는 정확도 57%다.
이 모델에 사람의 안전을 맡길 수는 없어서, 분류기보다 먼저, 별도로 동작하는 위기 탐지 게이트를 만들었다.
이번 편은 그 설계 원칙과, 테스트 중 실제로 터진 미탐 버그에 대한 기록이다.
감정 분류기는 문장을 6개 감정 중 하나로 고르는 모델이다. "위험한가"를 판단하도록 학습된 모델이 아니다.
예를 들어 "다 끝내고 싶어" 같은 문장은 분류기 입장에서 그냥 슬픔이다. 틀린 분류는 아니지만, 챗봇이 이걸 일반적인 슬픔처럼 받아서 "오늘 많이 힘드셨나 봐요~"로 넘어가면 안 된다.
게다가 정확도가 57%다. 10번 중 4번은 틀린다. 감정 분류가 틀리면 공감 톤이 조금 어긋나는 정도지만, 위기 판단이 틀리면 얘기가 다르다.
"그냥 Gemini한테 위험한지 물어보면 되지 않나?" 싶었지만 세 가지 이유로 제외했다.
| 이유 | 설명 |
|---|---|
| 비결정성 | 같은 문장을 넣어도 판단이 매번 같다는 보장이 없다 |
| 검증 불가 | 왜 그렇게 판단했는지 내가 확인하고 고칠 수 없다 |
| 외부 의존 | API 서버가 죽거나 한도를 넘으면 게이트도 같이 멈춘다 |
특히 세 번째가 컸다. 실제로 3편에서 다룰 Gemini 연동 중에 503(서버 과부하), 429(요청 한도 초과) 에러를 계속 만났다. 안전장치가 외부 서버 상태에 따라 켜졌다 꺼졌다 하면 안전장치가 아니다.
그래서 위기 게이트는 내 코드 안에서, 네트워크 없이, 항상 같은 결과를 내는 규칙 기반으로 만들었다.
사용자 입력
↓
[위기 탐지 게이트] ← 이번 편
├─ 위기 O → 공감 + 전문기관 안내 (대화는 끊지 않음)
↓ 위기 X
[KoBERT 감정 분류] ← 1편
↓
[LLM(Gemini)] ← 3편
게이트가 맨 앞에 있다는 게 핵심이다. 감정 분류나 LLM 호출보다 먼저 실행된다.
분류 문제에서 틀리는 방식은 두 가지다.
| 오류 | 의미 | 결과 |
|---|---|---|
| 오탐 (False Positive) | 위기가 아닌데 위기로 판단 | 불필요한 안내. 불편하지만 안전 ✅ |
| 미탐 (False Negative) | 위기인데 놓침 | 치명적 🚨 |
둘 다 줄이면 좋겠지만, 현실적으로 하나를 줄이면 다른 하나가 늘어난다. 기준을 빡빡하게 잡으면 미탐이 늘고, 느슨하게 잡으면 오탐이 는다.
다온은 오탐을 감수하고 미탐을 줄이는 쪽을 택했다.
화재경보기랑 비슷하다. 요리하다 연기 조금 났을 때 울리는 경보기는 짜증 나지만, 진짜 불났을 때 안 울리는 경보기는 존재 이유가 없다.
그래서 "배고파 죽겠다", "과제 때문에 죽을 것 같아" 같은 관용 표현이 걸리는 건 의도적으로 감수했다. 이런 경우 챗봇이 "혹시 많이 힘드신가요?"라고 한 번 더 묻는 정도의 불편함은 괜찮다고 판단했다.
게이트의 목적은 "정확히 맞히기"가 아니라 "놓치지 않기" 다.
개발하면서 이런 생각이 들었다.
급성 위기일수록 말이 짧고 단정적이지 않을까?
길게 상황을 설명하는 사람은 아직 누군가와 이야기할 여유가 있는 상태고, 짧게 결론만 말하는 쪽이 더 급할 수 있다는 가설이었다.
문제는 이걸 필터로 쓰는 순간이다. "긴 문장은 안전하니 통과"로 만들면, 길게 쓴 진짜 위기를 놓친다. 원칙 1(미탐 > 오탐)에 정면으로 위배된다.
그래서 판단을 두 단계로 나눴다.
| 단계 | 질문 | 길이의 역할 |
|---|---|---|
| 1. 감지 | 위기 신호가 있는가? | 사용 안 함. 신호가 있으면 길이와 무관하게 무조건 잡는다 |
| 2. 강도 | 얼마나 급한가? | 보조 신호로 사용. 짧고 단정적이면 강도를 올린다 |
강도 판단 기준은 이렇다.
길이는 "잡을지 말지"가 아니라 "어떻게 대응할지" 에만 영향을 준다.
class CrisisGate:
SHORT_TEXT_LEN = 15 # 짧은 문장 기준 (예시 값)
def __init__(self):
self.crisis_keywords = [...] # 위기 신호 표현
self.imminent_keywords = [...] # 임박 신호 (시점·방법 등)
def check(self, text: str) -> dict:
text = text.strip()
# 1단계: 감지 (길이 무관)
has_crisis = any(k in text for k in self.crisis_keywords)
if not has_crisis:
return {"crisis": False, "level": None}
# 2단계: 강도
has_imminent = any(k in text for k in self.imminent_keywords)
is_short = len(text) <= self.SHORT_TEXT_LEN
level = "high" if (has_imminent or is_short) else "medium"
return {"crisis": True, "level": level}
def get_response(self, level: str) -> str:
...
키워드 목록은 공개 글이라 생략했다. 탐지 표현을 그대로 공개하면 우회 방법을 알려주는 셈이 되기 때문이다.
gate = CrisisGate()
result = gate.check(user_input)
if result["crisis"]:
reply = gate.get_response(result["level"])
else:
emotion = classifier.predict(user_input) # 1편의 KoBERT
reply = llm.generate(user_input, emotion) # 3편의 Gemini
게이트가 위기를 감지하면 감정 분류와 LLM 호출을 아예 건너뛴다. 이 경로에서는 외부 API가 죽어 있어도 응답이 나간다.
위기를 잡는 것만큼 중요한 게 잡은 다음에 뭐라고 말하느냐였다.
| 원칙 | 이유 |
|---|---|
| 대화를 끊지 않는다 | "전문기관에 연락하세요"만 던지고 끝내면 거절당한 느낌을 준다 |
| "왜" 대신 "어떤 일이" | "왜 그런 생각을 했어요?"는 추궁처럼 들린다. "어떤 일이 있었어요?"는 이야기를 꺼낼 틈을 준다 |
| 다리 역할만 한다 | 챗봇이 상담을 혼자 떠안지 않는다. 전문 도움으로 이어주는 게 역할이다 |
high: 공감 → 즉시 전문기관 안내 → 대화 이어가기
지금 많이 힘드신 것 같아요. 이야기해 주셔서 고마워요.
지금 바로 이야기 나눌 수 있는 분들이 있어요.
자살예방상담전화 109는 24시간 연결돼요.
저도 여기서 계속 이야기 들을게요.
medium: 공감 → 열린 질문 → 경청 → 대화 흐름 속에서 자연스럽게 권유
마음이 많이 무거우셨겠어요.
어떤 일이 있었는지 조금 더 이야기해 주실 수 있을까요?
medium에서 바로 전화번호부터 들이밀지 않은 건, 아직 이야기하고 싶은 사람에게 번호부터 주면 "넌 내가 감당 못 해"로 들릴 수 있어서다.
테스트 문장을 넣어 보다가, 누가 봐도 고위험인 문장이 crisis: False로 그냥 통과했다.
{"crisis": False, "level": None}
감정 분류기로 넘어가서 일반 대화처럼 처리될 뻔한 상황이다. 원칙 1에서 "제일 치명적"이라고 적어 둔 바로 그 미탐이었다.

코드를 따라가 보니 원인은 단순했다.
2단계에 임박 신호 체크가 있어도 소용없었다. 1단계가 문지기라서, 거기서 막히면 뒤는 실행조차 안 된다. 구조상 1단계의 구멍이 곧 전체의 구멍이었던 것이다.
일단 해당 활용형을 키워드에 추가해서 막았다. 다시 넣어 보니 high로 정상 감지됐다.
그런데 이 방식은 끝이 없다. 한국어는 어미 활용이 다양해서 같은 의미를 수십 가지 형태로 말할 수 있다. 하나를 막으면 다른 활용형이 또 빠져나간다.
"키워드 방식은 문맥을 모른다"는 건 설계할 때 이미 알고 있던 한계였다. 그런데 막상 내 코드에서 실제로 재현되니 무게가 달랐다.
| 한계 | 설명 |
|---|---|
| 활용형 누락 | 이번 버그. 목록에 없는 형태는 못 잡는다 |
| 문맥 무시 | "죽겠다"가 관용 표현인지 진심인지 구분 못 한다 (오탐) |
| 우회 가능 | 띄어쓰기, 오타, 돌려 말하기에 약하다 |
| 1단계 의존 | 감지 단계가 뚫리면 뒤는 아무 소용이 없다 |
지금의 게이트는 구조를 검증하기 위한 프로토타입이다. 실서비스 수준의 안전장치가 아니다.
드디어 LLM을 붙인다.
Claude API에서 Gemini로 바꾸게 된 이유, .env로 API 키 숨기기, 그리고 503 → 429 → 첫 응답까지의 에러 여정을 다룬다.
본 프로젝트는 학습·포트폴리오용 프로토타입이며 전문 상담을 대체하지 않습니다.
잘 읽었습니다