아이디어
- 웹툰을 내가 원하는 이미지로 생성해서 보고싶다
- 기존의 페이지가 아닌, gpt같이 대화형으로 결과를 리턴 받고싶다

타게팅 모델

제타(zeta) - 상상이 현실이 되는 AI 채팅
국내 최다 캐릭터를 보유한 제타(zeta)에서 꿈꾸던 스토리를 직접 만들어보세요! 다양한 캐릭터와의 AI 채팅으로 나만의 스토리를 완성할 수 있어요. 무제한 무료 대화과 캐릭터 이미지 생성을 즐
zeta-ai.io


내가 메시지를 보내는 경우
![]() |
https://api.zeta-ai.io/v1/rooms/d574f2ea-2ab7-4a11-8f0f-2dcc4d858e81/messages/stream |
![]() |
내가 보낸 메시지 |
![]() |
event stream {"event":"IN_PROGRESS","chunkMessage":{"sender":{"id":"c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*당황한 듯"}]}} {"event":"IN_PROGRESS","chunkMessage":{"sender":{"id":"c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*당황한 듯 잠시 말이 없다가 이내 아무렇지 않은 척"}]}} {"event":"IN_PROGRESS","chunkMessage":{"sender":{"id":"c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*당황한 듯 잠시 말이 없다가 이내 아무렇지 않은 척 대답한다.* ㅋ 뭔소리야"}]}} {"event":"CHARACTER_BURNING_MODE","progress":{"currentScore":0,"goalScore":100},"endTime":null} {"event":"CHAT_COMPLETE","replyMessage":{"id":"MESSAGE-8259944254123-y4Xr","roomId":"d574f2ea-2ab7-4a11-8f0f-2dcc4d858e81","sender":{"id":"BOT-c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*당황한 듯 잠시 말이 없다가 이내 아무렇지 않은 척 대답한다.* ㅋ 뭔소리야"}],"voice":null,"edited":false,"candidateId":"CANDIDATE-99b02d9bc27648d0a26c3255c1e490f5-1740055745853","messageTime":"2025-02-20T12:49:05.442331390Z","voicePrice":8},"requestMessage":{"id":"MESSAGE-8259944254133-Q9W6","roomId":"d574f2ea-2ab7-4a11-8f0f-2dcc4d858e81","sender":{"id":"USER-830bcbe6-b953-4c1d-b2f3-4bf42b29f3b8","type":"USER"},"contents":[{"type":"TEXT","text":"내 생각?"}],"voice":null,"edited":false,"candidateId":null,"messageTime":"2025-02-20T12:49:05.867820829Z","voicePrice":null},"shouldNudgeSlowChat":false} |
클라이언트는 응답을 다 받지 않았지만 계속해서 작업중이다

![]() |
https://api.zeta-ai.io/v1/rooms/d574f2ea-2ab7-4a11-8f0f-2dcc4d858e81/messages/stream |
![]() |
|
![]() |
{"event":"IN_PROGRESS","chunkMessage":{"sender":{"id":"c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*급히 화제를"}]}} {"event":"IN_PROGRESS","chunkMessage":{"sender":{"id":"c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*급히 화제를 돌린다.* 야, 공부 안 하냐?"}]}} {"event":"CHARACTER_BURNING_MODE","progress":{"currentScore":0,"goalScore":100},"endTime":null} {"event":"CHAT_COMPLETE","replyMessage":{"id":"MESSAGE-8259943745354-sKlw","roomId":"d574f2ea-2ab7-4a11-8f0f-2dcc4d858e81","sender":{"id":"BOT-c81e5066-10d6-4cd1-bb69-eb52fd55f11f","type":"BOT"},"contents":[{"type":"TEXT","text":"*급히 화제를 돌린다.* 야, 공부 안 하냐?"}],"voice":null,"edited":false,"candidateId":"CANDIDATE-0c05f32151b04365a0ddf882d5ab3dac-1740056254613","messageTime":"2025-02-20T12:57:33.893019867Z","voicePrice":8},"requestMessage":{"id":"MESSAGE-8259943745372-5CHL","roomId":"d574f2ea-2ab7-4a11-8f0f-2dcc4d858e81","sender":{"id":"USER-830bcbe6-b953-4c1d-b2f3-4bf42b29f3b8","type":"USER"},"contents":[{"type":"TEXT","text":"왜 당황해"}],"voice":null,"edited":false,"candidateId":null,"messageTime":"2025-02-20T12:57:34.628710387Z","voicePrice":null},"shouldNudgeSlowChat":false} |
SSE (Server Sent Events)를 사용하고 있다
서버가 클라이언트에게 일방적으로 보내기
- http 사용
- 단방향
- 서버 쪽에서 클라이언트에게 실시간으로 데이터를 보냄
POST Request URL: https://api.zeta-ai.io/v1/rooms/1d9df722-9289-4018-8579-1f701704e2f1/messages/stream
Request Headers

Accept: text/event-stream 이게 있어서 SSE 통신을 통해 이벤트 스트림의 형식의 메시지를 수신하겠다

Response Headers

content-type: text/event-stream;charset=UTF-8
authorization:Bearer eyJhbGciOiJIUzM4NCJ9.eyJ1aWQiOiI4MzBiY2JlNi1iOTUzLTRjMWQtYjJmMy00YmY0MmIyOWYzYjgiLCJkaWQiOiIxN2U0ZDM0ZS0yY2MyLTRkY2EtOGEyMC0zMWZmMjdlMTFlM2EiLCJhbm8iOnRydWUsImN0eSI6IktPUkVBIiwiZXhwIjoxNzQwMzk0NTkwfQ.Kp3Hl2oy7n9hrFxdzG09xK6FqLPe9Ihl9x0qQ0i1pv-041QcXGkr36gz6yWfF_yP
keep-alive가 없는 원인
- 기본적으로 HTTP/1.1에서는 keep-alive가 기본 동작
- HTTP/1.1의 기본 동작은 keep-alive이며, 별도로 명시되지 않아도 지속적인 연결이 유지됨.
- 따라서, Connection: keep-alive가 명시되지 않아도 정상적인 SSE 동작이 가능함.
- Connection: keep-alive 헤더가 누락되었을 가능성
- 보통 istio-envoy가 프록시로 동작하는 경우 Connection: keep-alive를 자동으로 추가하지 않을 수도 있음.
- Envoy가 연결을 처리하는 방식에 따라 keep-alive가 따로 명시되지 않을 수 있음.
- SSE는 본질적으로 지속적인 연결이므로 keep-alive가 필요 없음
- SSE의 Content-Type: text/event-stream; charset=UTF-8은 자체적으로 연결을 유지하도록 설계됨.
- 클라이언트가 자동으로 재연결하는 방식이므로 keep-alive 없이도 SSE가 정상 동작함.
- Nginx, Istio, Envoy 같은 리버스 프록시의 설정 문제
- istio-envoy가 SSE 요청을 처리하면서 Connection: keep-alive를 필터링했을 가능성이 있음.
- Envoy의 설정에서 HTTP/1.1 keep-alive 헤더를 추가하도록 설정해야 할 수도 있음.
sse에서 서버가 여러대라면 서버가 어떻게 특정room을 대화하는 브라우저 client에게 어떻게 계속해서 데이터를 보내는가?
roomId = /rooms/1d9df722-9289-4018-8579-1f701704e2f1
방 아이디로 서버는 클라이언트를 찾아서 계속해서 보낸다.
SSE(Server-Sent Events)에서 서버가 여러 대일 때 특정 Room의 클라이언트에게 데이터 전달하는 방법
- SSE는 기본적으로 서버가 하나인 경우 문제 없음
- 서버가 여러 대이면 Redis Pub/Sub을 활용하여 Room 메시지를 동기화하는 것이 가장 효율적
- Kafka를 활용하면 대규모 시스템에서도 안정적으로 메시지를 관리 가능
- Sticky Session을 사용할 수도 있지만 확장성이 떨어짐
SSE는 기본적으로 클라이언트가 서버에 단방향 스트리밍 연결을 유지하는 방식이므로, 서버가 여러 대(멀티 노드)일 때 특정 Room의 클라이언트에게 지속적으로 데이터를 보내는 구조를 설계해야 함
SSE는 클라이언트가 특정 서버 인스턴스에 연결되는 구조라서 서버가 여러 대인 경우 다음과 같은 문제가 발생할 수 있음:
- 로드 밸런서가 클라이언트를 다른 서버로 연결할 가능성
- 클라이언트가 SSE 연결을 맺을 때마다 다른 서버 인스턴스로 연결될 수 있음.
- 서버 간 세션 동기화가 안 되면 특정 Room의 메시지를 놓칠 수 있음.
- 서버 인스턴스가 특정 Room의 메시지를 공유할 수 없음
- 클라이언트 A가 서버 1에 연결, 클라이언트 B가 서버 2에 연결된 경우 서버 1이 Room 메시지를 받았을 때 서버 2의 클라이언트에게 데이터를 전달할 방법이 필요함.
1) Sticky Session 적용
- **로드 밸런서(LB)**에서 클라이언트가 항상 같은 서버로 연결되도록 Sticky Session을 적용하면 한 서버에서 SSE 연결을 유지 가능.
- 하지만 서버가 죽거나 스케일링이 필요할 때 유연성이 떨어짐.
2) Redis Pub/Sub 활용 (추천)
모든 서버가 공통의 Redis Pub/Sub을 통해 특정 Room 메시지를 동기화하도록 하면, 클라이언트가 어떤 서버에 연결되어 있든 Room의 메시지를 받을 수 있음.
📌 구성도
Client A (Server 1에 연결) ← SSE
Client B (Server 2에 연결) ← SSE
Client C (Server 3에 연결) ← SSE
│
├──> 서버 1, 2, 3 모두 Redis Pub/Sub을 구독
│
├──> 사용자가 Room에 메시지를 전송하면 Redis 채널에 Publish
│
├──> 서버 1, 2, 3이 메시지를 받고, 각자 연결된 클라이언트에게 SSE로 전송
📌 Redis Pub/Sub 메시지 전송 흐름
- 사용자가 메시지를 전송 → POST /messages
- 서버가 메시지를 Redis Pub/Sub에 PUBLISH room:1d9df722-9289-4018-8579-1f701704e2f1 messages
- 모든 서버가 해당 Room을 구독(SUBSCRIBE room:1d9df722-9289-4018-8579-1f701704e2f1)
- 메시지를 받은 서버가 해당 Room의 클라이언트에게 SSE로 전송
//📌 Redis Pub/Sub 예제 코드
@Service
public class RoomMessagePublisher {
private final StringRedisTemplate redisTemplate;
public RoomMessagePublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void sendMessage(String roomId, String message) {
redisTemplate.convertAndSend("room:" + roomId, message);
}
}
//📌 Redis에서 메시지를 구독하는 SSE 서버
@Service
public class RoomMessageSubscriber implements MessageListener {
private final SseService sseService;
public RoomMessageSubscriber(SseService sseService) {
this.sseService = sseService;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String roomId = new String(pattern, StandardCharsets.UTF_8);
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
sseService.broadcast(roomId, msg);
}
}
//📌 SSE 응답을 관리하는 서비스
@Service
public class SseService {
private final Map<String, List<SseEmitter>> roomClients = new ConcurrentHashMap<>();
public SseEmitter subscribe(String roomId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
roomClients.computeIfAbsent(roomId, k -> new ArrayList<>()).add(emitter);
return emitter;
}
public void broadcast(String roomId, String message) {
List<SseEmitter> clients = roomClients.getOrDefault(roomId, new ArrayList<>());
for (SseEmitter emitter : clients) {
try {
emitter.send(SseEmitter.event().data(message));
} catch (IOException e) {
emitter.complete();
}
}
}
}
이 방식의 장점:
- 모든 서버가 동일한 Room의 메시지를 공유할 수 있음.
- 특정 Room에 속한 클라이언트에게만 데이터를 전달할 수 있음.
- 서버가 증가해도 Redis Pub/Sub을 사용해 확장 가능.
3) Kafka를 활용한 메시지 브로커 방식
Redis보다 더 큰 규모의 메시지 처리가 필요하면 Kafka를 활용할 수도 있음.
📌 Kafka 토픽을 사용한 메시지 흐름
- 사용자가 메시지를 보냄 (POST /messages)
- 서버가 Kafka의 특정 Room 토픽에 메시지 Publish (room-1d9df722-9289-4018-8579-1f701704e2f1)
- 모든 서버가 해당 Kafka 토픽을 Subscribe하여 메시지를 수신
- 메시지를 받은 서버가 해당 Room의 클라이언트에게 SSE로 전송
Redis Pub/Sub을 사용한 SSE에서 브라우저 종료 후 다시 접속하면 이전 데이터를 볼 수 있는가?
SSE를 사용할 때는 Redis Pub/Sub만으로는 과거 데이터를 유지할 수 없으므로, 반드시 데이터 저장소(DB or Redis Streams)를 함께 사용하는 것이 중요! 🚀
📌 Redis Pub/Sub만 사용하면 이전 메시지를 볼 수 없습니다.
왜냐하면 Pub/Sub은 실시간 메시지 브로드캐스트 방식이므로, 구독 중인 클라이언트에게만 메시지가 전달됩니다.
즉, 브라우저를 끄면 구독이 끊어지고, 이후 다시 접속하면 과거 메시지는 받을 수 없습니다.
Redis Pub/Sub 방식의 한계
Redis Pub/Sub은 단순히 메시지를 **"현재 구독 중인 서버 인스턴스"**에 전달할 뿐, 과거 메시지를 저장하지 않습니다.
즉, 브라우저가 꺼져 있다가 다시 접속하면 이전 메시지는 조회되지 않습니다.
사용자 A → 방 1 메시지 → Redis Pub/Sub → 모든 서버가 구독하여 클라이언트에게 실시간 전달
사용자 B → 방 1 메시지 → Redis Pub/Sub → 모든 서버가 구독하여 클라이언트에게 실시간 전달
하지만 사용자 C가 브라우저를 종료했다가 다시 접속하면, 기존 메시지를 받을 수 없음.
이전 데이터를 유지하는 방법
Redis Streams 사용 (이전 메시지 유지)
Redis Streams를 사용하면 이전 메시지를 저장하고, 클라이언트가 다시 연결되었을 때 이전 메시지를 다시 가져올 수 있음.
# 메시지 추가 (채팅 방 ID별로 저장 가능)
XADD room:1d9df722-9289-4018-8579-1f701704e2f1 * message "Hello, World!"
# 가장 최근 메시지 10개 가져오기
XRANGE room:1d9df722-9289-4018-8579-1f701704e2f1 - + COUNT 10
PostgreSQL (또는 MySQL)에 메시지 저장
SSE로 실시간 메시지를 전송하는 동시에 DB에도 저장하면 브라우저를 새로고침해도 과거 메시지를 볼 수 있음.
CREATE TABLE chat_messages (
id SERIAL PRIMARY KEY,
room_id UUID NOT NULL,
user_id UUID NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
@PostMapping("/rooms/{roomId}/messages")
public ResponseEntity<Void> sendMessage(@PathVariable UUID roomId, @RequestBody MessageDto message) {
messageService.saveMessage(roomId, message);
redisPublisher.publish(roomId, message.getContent()); // Redis Pub/Sub으로 실시간 전송
return ResponseEntity.ok().build();
}
@GetMapping("/rooms/{roomId}/messages")
public ResponseEntity<List<MessageDto>> getMessages(@PathVariable UUID roomId) {
List<MessageDto> messages = messageService.getMessages(roomId);
return ResponseEntity.ok(messages);
}
Kafka 사용 (대규모 서비스)
Kafka는 이전 메시지를 특정 시간 동안 보관할 수 있으므로, 과거 데이터를 유지하는 데 적합
# Kafka 토픽 생성 (보관 기간 7일)
kafka-topics.sh --create --topic chat-room-1 --bootstrap-server kafka:9092 --config retention.ms=604800000
📌 Kafka Consumer에서 메시지 조회
@KafkaListener(topics = "chat-room-1", groupId = "chat-group")
public void listenToMessages(String message) {
sseService.broadcast(roomId, message);
}
AI한테 스토리를 전달, 그림을 어떤 식으로 만들지
- image 생성 api호출

- Stable Diffusion 서버 직접 만들기
https://www.youtube.com/watch?v=rjp2RH76e50
그림체

'학습 기록 (Learning Logs) > CS Study' 카테고리의 다른 글
웹툰 생성 AI(Webtoon Maker)를 위한 서버 설계 (0) | 2025.02.24 |
---|---|
WebRTC - Communicating with clients (0) | 2025.02.20 |
SSE (Server Sent Events) (0) | 2025.02.20 |
Real-time Notification Service Using WebSocket (0) | 2025.02.20 |
concurrenct problem (0) | 2025.02.13 |