Architecture Log
ChatGPT랑 가까워지기
요즘 여기저기 AI를 많이 사용하면서 더 친해지기 위해 알아본 내용 남겨두기!
(AI 서비스 전반에 해당하는 내용이지만, 편의상 ChatGPT로 작성)
먼저, ChatGPT 응답은 어떻게 타이핑처럼 나올까?
일단 웹소켓이랑 SSE가 떠올랐다.
그런데 ChatGPT는 서버 → 클라이언트 방향만 실시간이다. 내가 질문을 보내는 건 그냥 HTTP POST로 충분하고, AI가 답변을 스트리밍하는 것만 실시간이 필요하다.
그래서 SSE(Server-Sent Events)가 적절하다. 단방향 실시간 통신!
유저: "파이썬이란?" → HTTP POST
서버: "파이썬은..." → SSE로 토큰 하나씩 전송
그럼 왜 타이핑처럼 나오냐? AI 모델이 답변을 한 번에 생성하는 게 아니라, 토큰(단어) 단위로 순서대로 생성하기 때문이다.
생성될 때마다 SSE로 바로 클라이언트에 쏴주니까 타이핑 효과가 나오는 거다.
근데 SSE 연결을 어떻게 유지할까?
SSE는 HTTP 연결 기반이라 Heartbeat 방식을 쓴다.
서버가 주기적으로 빈 메시지 전송
: keep-alive\n\n (15~30초마다)
↓
클라이언트가 받으면 → 연결 살아있음 확인
클라이언트가 못 받으면 → 연결 끊겼다고 판단
↓
클라이언트가 자동으로 재연결 시도
(WebSocket은 끊기면 재연결 로직을 직접 구현해야 하는데, SSE는 브라우저가 자동으로 재연결!)
근데 여기서 두 가지 문제가 생긴다.
1. 연결 유지 비용
10만 명이 SSE 연결 유지 중
→ 서버가 10만 개 연결을 계속 들고 있어야 함
→ 메모리 부하!
이건 논블로킹으로 해결한다.
연결 10만 개 = 스레드 10만 개 (블로킹) 💀
연결 10만 개 + 스레드 수백 개 (논블로킹) 😎
2. 서버 여러 대 문제
유저 A → 서버 1에 SSE 연결
응답 이벤트 → 서버 2에서 발생
→ 서버 2는 유저 A 연결 모름 💀
이건 Redis Pub/Sub으로 해결한다.
이벤트 발생 → 서버 2
↓
Redis Pub/Sub channel:userId 발행
↓
서버 1이 구독 중 → 감지
↓
유저 A SSE 연결로 전송 😎
결국 아래처럼 된다.
SSE → 실시간 단방향 전송
논블로킹 → 연결 유지 비용 최소화
Redis Pub/Sub → 서버간 이벤트 공유
그럼 스레드는 어떻게 되는 거지?
AI 모델 추론은 느리다. 긴 답변이면 수십 초도 걸린다.
그 동안 서버 스레드가 그냥 붙잡혀 있으면 스레드가 잡혀서 새 요청을 못 받는다. (DAU 1000만이면 동시 요청이 수만건,,)
그래서 논블로킹 비동기가 필요하다.
블로킹 방식 (문제)
스레드: AI 응답 기다리는 중... [대기 대기 대기] ...완료
동시 요청 1만 개 = 스레드 1만 개 필요 💀
논블로킹 방식
스레드: AI한테 요청 보내고 → 다른 요청 처리하러 감
→ 토큰 생성됐다는 콜백 오면 → SSE로 전송
스레드 수백 개로 수만 요청을 처리할 수 있다.
💡 당연히 무조건 논블로킹이 답은 아니다! 작업이 빠르게 끝나거나 동시 접속자가 많지 않다면 오히려 블로킹이 더 단순하고 적절할 수 있다. 논블로킹은 스레드 고갈이 실제로 우려될 때 도입 고려 필요!
장애 상황으로 가보자!
AI 모델 서버가 장애났고, 유저들이 재시도를 폭발적으로 날리기 시작하며 장애가 더 악화된다.
(이를 Thundering Herd Problem 이라고 한다)
장애 발생
↓
유저 재시도 폭탄
↓
서버 부하 가중
↓
장애 더 심해짐
↓
유저 더 많이 재시도
↓
💀 악순환
이걸 막는 클라이언트 측과 서버 측 방법이 뭐가 있을까?
클라이언트 측 — Exponential Backoff
Exponential Backoff는 클라이언트 코드가 자동으로 재시도 간격을 늘려가는 방식이다.
유저: 전송 버튼 한 번 클릭
↓
클라이언트 코드가 알아서 재시도
→ 실패 → 1초 대기 → 재시도
→ 실패 → 2초 대기 → 재시도
→ 실패 → 4초 대기 → 재시도
↓
유저는 로딩 스피너만 보고 있음
모든 유저가 같은 간격으로 재시도하면 부하가 몰리는데, 간격을 늘리면 자연스럽게 분산된다.
여기에 Jitter(무작위 지연) 를 추가하면 더 효과적이다. 유저마다 조금씩 다른 타이밍에 재시도하게 만드는 거다.
그치만 유저가 수동으로 새로고침하는 건 막을 수 없다! 그 경우는 서버 측 Circuit Breaker가 담당한다.
서버 측 — Circuit Breaker
장애를 감지하면 요청 자체를 차단해버리는 서킷브레이커는 세 가지 상태가 있다.
CLOSED (정상)
→ 요청 통과, 에러율 모니터링 중
OPEN (차단)
→ 에러율 임계치 초과
→ 요청 즉시 차단, 빠르게 실패 반환
→ 서버에 부하가 안 감
HALF-OPEN (복구 확인 중)
→ 일정 시간 후 소수 요청만 통과시켜봄
→ 성공하면 CLOSED 복구
→ 실패하면 다시 OPEN
CLOSED가 '정상 상태'라 직관적으로 반대처럼 느껴진다. (서킷브레이커 관점이라!)
에러율 임계치나 HALF-OPEN 테스트 요청 수 등은 서비스 특성에 맞게 조정이 필요하다.
ex)
CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 에러율 50% 이상이면 OPEN
.waitDurationInOpenState(10s) // 10초 후 HALF-OPEN 전환
.permittedNumberOfCallsInHalfOpenState(10) // 10개만 테스트
.build()
근데 분산 환경에서 생각해보면 문제가 있다.
Circuit Breaker는 AI 모델 같은 외부 의존성에 문제가 생겼을 때 요청을 차단하는 역할인데, 보통 각 서버 메모리에 상태를 저장한다. (서버 자체 문제는 로드밸런서 헬스체크가 담당!)
그런데 서버마다 AI 모델에 보내는 요청 수가 다르다 보니 에러율이 달라지고, 상태도 달라진다.
AI 모델 서버 장애 발생
↓
서버 1 → AI 모델 요청 100번 중 80번 실패 → 에러율 80% → 메모리에 OPEN 저장
서버 2 → AI 모델 요청 10번 중 4번 실패 → 에러율 40% (임계치 미달) → 메모리에 CLOSED 유지
그런데 로드밸런서가 요청을 분산하니까 👇
유저 A → 서버 2로 라우팅 → CLOSED → AI 모델 호출 → 장애 중이라 실패 💀
유저 B → 서버 1로 라우팅 → OPEN → AI 모델 호출 안 함 → 즉시 fallback 반환 😎
같은 AI 모델 장애 상황인데 어느 서버로 가느냐에 따라 유저 경험이 완전히 달라진다!
그래서 Circuit Breaker 상태를 Redis에 저장해서 모든 서버가 공유하도록 한다.
서버 1 → AI 모델 에러 감지 → Redis에 OPEN 업데이트
서버 2 → Redis 조회 → OPEN 확인 → AI 모델 요청 차단 😎
서버 3 → Redis 조회 → OPEN 확인 → AI 모델 요청 차단 😎
↓
모든 서버가 같은 상태 공유, AI 모델에 불필요한 요청 안 보냄 😎
근데 그 Redis가 장애나면?
이건 Fail-Open vs Fail-Closed 전략으로 결정한다.
Fail-Open (기본값: 통과)
→ 상태 모르면 일단 요청 통과시킴
→ 서비스는 살아있음
→ 근데 AI 모델도 장애 중이면 요청 다 실패 💀
Fail-Closed (기본값: 차단)
→ 상태 모르면 일단 차단
→ 안전하지만 멀쩡한 요청도 막힘 💀
정답은 없고 서비스 특성에 따라 다르다.
ChatGPT 같은 경우 → Fail-Open
→ AI 모델이 살아있을 수도 있으니 일단 통과
→ 실패하면 그때 에러 반환
금융 서비스 같은 경우 → Fail-Closed
→ 잘못된 요청 통과보다 차단이 안전
Circuit Breaker가 차단하면, 유저한테 뭘 보여줄까?
단순히 에러 페이지만 띄우는 건,, 유저 입장에서는 답답하고, 서비스 입장에서는 아깝다.
전체를 죽이지 않고 핵심 기능만 살리고 나머지를 비활성화하는 방식이 있다!
(Graceful Degradation(우아한 기능 저하) 개념)
ChatGPT 실제 장애 때를 생각해보면:
GPT-4 장애
→ GPT-3.5로 폴백 (품질은 낮지만 서비스는 살아있음)
스트리밍 장애
→ 타이핑 효과 없이 완성된 응답으로 폴백
AI 전체 장애
→ 캐싱된 FAQ성 응답 제공
코드로 보면 이렇다.
fun getAIResponse(prompt: String): String {
return try {
gpt4Client.call(prompt) // 먼저 GPT-4 시도
} catch (e: Exception) {
gpt35Client.call(prompt) // 실패하면 GPT-3.5로 폴백
}
}
근데 여기서 현실적인 문제가 있다.
GPT-4 구독자한테 GPT-3.5로 폴백했으면?
돈 내고 낮은 품질 쓰는 건데,, 이건 기술보다 아니라 비즈니스 의사결정이 필요하다.
폴백 후 배너로 안내하고, 장애 복구 후 구독 연장이나 크레딧 보상을 주는 방향으로 보통 진행하지 않을까 싶다.
AI 전체 장애 → 캐싱된 FAQ성 응답 제공이라고 했는데, 여기서 현실적인 문제가 있다.
내용은 같아도 질문 형식이 다 다를텐데 어떻게 캐싱을...?
"파이썬이란?" → 캐싱
"파이썬이 뭐야?" → 캐시 미스 💀
"Python 설명해줘" → 캐시 미스 💀
캐시 사용 의미가 없을 것 같단 말이지.
그래서 시맨틱 캐싱을 사용한다고 한다.
질문 → 임베딩(벡터)으로 변환
"파이썬이란?" → [0.2, 0.8, 0.1, ...]
"파이썬이 뭐야?" → [0.21, 0.79, 0.11, ...]
↓
코사인 유사도 비교
유사도 90% 이상 → 같은 질문으로 판단 → 캐시 히트 😎
근데 이것도 완벽하진 않다.
"파이썬 장점이 뭐야?" → [0.5, 0.7, ...]
"파이썬 단점이 뭐야?" → [0.51, 0.69, ...]
→ 벡터가 비슷해서 잘못된 캐시 히트 위험 💀
유사도 임계치를 너무 낮추면 잘못된 응답을 반환하고,,, 너무 높이면 캐시 히트율이 낮아진다.
결국 AI 서비스에서 캐싱보다는 Circuit Breaker + Fallback 전략이 더 현실적인 장애 대응이다.
마무리
단순 궁금증으로 떠올렸던 건데, 꼬리질문이 이어지면서 재밌는 것들을 많이 알게됐다!
너무 길어져서 블로그엔 다 담지 못했지만,, 더 파고들면 재밌는게 더 나올 것 같아서 좀 더 알아봐야겠다