본문 바로가기

학습 기록 (Learning Logs)/CS Study

AI를 활용한 webtoon maker 만들기

아이디어

- 웹툰을 내가 원하는 이미지로 생성해서 보고싶다

- 기존의 페이지가 아닌, gpt같이 대화형으로 결과를 리턴 받고싶다

 

 

 

 

타게팅 모델

https://zeta-ai.io/ko

 

제타(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가 없는 원인

  1. 기본적으로 HTTP/1.1에서는 keep-alive가 기본 동작
    • HTTP/1.1의 기본 동작은 keep-alive이며, 별도로 명시되지 않아도 지속적인 연결이 유지됨.
    • 따라서, Connection: keep-alive가 명시되지 않아도 정상적인 SSE 동작이 가능함.
  2. Connection: keep-alive 헤더가 누락되었을 가능성
    • 보통 istio-envoy가 프록시로 동작하는 경우 Connection: keep-alive를 자동으로 추가하지 않을 수도 있음.
    • Envoy가 연결을 처리하는 방식에 따라 keep-alive가 따로 명시되지 않을 수 있음.
  3. SSE는 본질적으로 지속적인 연결이므로 keep-alive가 필요 없음
    • SSE의 Content-Type: text/event-stream; charset=UTF-8은 자체적으로 연결을 유지하도록 설계됨.
    • 클라이언트가 자동으로 재연결하는 방식이므로 keep-alive 없이도 SSE가 정상 동작함.
  4. 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는 클라이언트가 특정 서버 인스턴스에 연결되는 구조라서 서버가 여러 대인 경우 다음과 같은 문제가 발생할 수 있음:

  1. 로드 밸런서가 클라이언트를 다른 서버로 연결할 가능성
    • 클라이언트가 SSE 연결을 맺을 때마다 다른 서버 인스턴스로 연결될 수 있음.
    • 서버 간 세션 동기화가 안 되면 특정 Room의 메시지를 놓칠 수 있음.
  2. 서버 인스턴스가 특정 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 메시지 전송 흐름

  1. 사용자가 메시지를 전송 → POST /messages
  2. 서버가 메시지를 Redis Pub/Sub에 PUBLISH room:1d9df722-9289-4018-8579-1f701704e2f1 messages
  3. 모든 서버가 해당 Room을 구독(SUBSCRIBE room:1d9df722-9289-4018-8579-1f701704e2f1)
  4. 메시지를 받은 서버가 해당 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 토픽을 사용한 메시지 흐름

  1. 사용자가 메시지를 보냄 (POST /messages)
  2. 서버가 Kafka의 특정 Room 토픽에 메시지 Publish (room-1d9df722-9289-4018-8579-1f701704e2f1)
  3. 모든 서버가 해당 Kafka 토픽을 Subscribe하여 메시지를 수신
  4. 메시지를 받은 서버가 해당 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

 

 

그림체