DungeonTalk 백엔드 개발기 - 2편: AI 채팅 시스템 구현

MJ·2025년 8월 14일

실시간 턴제 AI 게임 채팅을 구현하며 마주한 기술적 도전들

들어가며

1편에서 멀티 데이터베이스 아키텍처를 살펴봤다면, 이번 편에서는 AI 채팅 시스템의 구체적인 구현을 다뤄보겠습니다. 실시간 WebSocket 통신과 외부 AI 서비스 연동, 그리고 턴제 게임 상태 관리까지 - 생각보다 복잡했던 여정을 공유합니다.

AI 채팅 시스템 아키텍처

핵심 설계 원칙

1. 턴제 게임 로직

  • 플레이어들이 순서대로 메시지 입력
  • AI 응답 중에는 모든 입력 차단
  • 게임 페이즈별 상태 관리

2. 실시간 통신

  • WebSocket + STOMP 기반 양방향 통신
  • 룸별 독립적인 메시지 채널
  • 자동 재연결 및 에러 처리

3. 외부 서비스 연동

  • Python AI 서비스와 HTTP 통신
  • 비동기 처리 및 타임아웃 관리
  • 장애 대응 및 폴백 처리

도메인 구조

패키지 구성

aichat/
├── controller/     - REST API & WebSocket 컨트롤러
├── service/        - 비즈니스 로직 처리
├── entity/         - MongoDB 엔티티
├── dto/            - 데이터 전송 객체
├── common/         - 열거형 및 상수
└── event/          - 비동기 이벤트 처리

핵심 컴포넌트

컨트롤러 레이어

  • AiGameRoomController: 게임방 CRUD API
  • AiChatStompController: 실시간 메시징 처리

서비스 레이어

  • AiGameFlowService: AI 턴 플로우 관리
  • AiGameStateService: 게임 상태 및 세션 관리
  • AiApiService: 외부 AI 서비스 연동

WebSocket 실시간 메시징

STOMP 메시지 매핑

@Controller
public class AiChatStompController {
    
    @MessageMapping("/aichat/send")
    public RsData<String> sendMessage(@Payload AiGameMessageSendRequest request) {
        return aiGameMessageService.handleWebSocketMessage(request);
    }
    
    @MessageMapping("/aichat/join")
    public RsData<String> joinRoom(@Payload AiGameMessageSendRequest request) {
        return aiGameMessageService.handleJoinRoom(request);
    }
}

메시지 플로우:
1. 클라이언트 → /pub/aichat/send
2. 서버 처리 → MongoDB 저장 → Redis Pub/Sub
3. 구독자들에게 → /sub/aichat/room/{roomId}

실시간 상태 동기화

핵심 아이디어는 게임 상태를 Redis에서 관리하면서 메시지는 MongoDB에 저장하는 것입니다.

  • Redis: 세션, 게임 페이즈, 턴 락 관리
  • MongoDB: 메시지 히스토리 영구 저장
  • WebSocket: 실시간 상태 브로드캐스트

게임 상태 관리

게임 페이즈 시스템

public enum AiGamePhase {
    WAITING,      // 플레이어 입장 대기
    TURN_INPUT,   // 플레이어 입력 단계  
    AI_RESPONSE,  // AI 응답 생성 중 (입력 차단)
    GAME_END      // 게임 종료
}

턴 락 메커니즘

문제: AI 응답 중에 여러 플레이어가 동시에 메시지를 보내면?

해결: Redis 분산 락으로 AI 처리 중 상태 관리

public boolean lockForAiResponse(String roomId) {
    String lockKey = AI_GAME_TURN_LOCK_PREFIX + roomId;
    boolean locked = valkeyService.setIfNotExists(lockKey, "AI_PROCESSING", 300);
    
    if (locked) {
        changePhase(roomId, AiGamePhase.AI_RESPONSE);
    }
    return locked;
}

동작 과정:
1. 플레이어 메시지 → AI 응답 요청
2. Redis 락 설정 → 페이즈를 AI_RESPONSE로 변경
3. 다른 메시지들은 차단
4. AI 응답 완료 → 락 해제 → TURN_INPUT으로 복귀

세션 관리 전략

public AiGameRoomResponse startGameSession(String roomId) {
    // MongoDB에서 게임 상태 변경
    AiGameRoom updatedRoom = room.toBuilder()
            .status(AiGameStatus.ACTIVE)
            .currentPhase(AiGamePhase.TURN_INPUT)
            .build();
    
    // Redis에 세션 정보 저장 (TTL 1시간)
    String sessionKey = AI_GAME_SESSION_PREFIX + roomId;
    valkeyService.setWithExpiration(sessionKey, sessionData, 3600);
    
    return AiGameRoomResponse.fromEntity(updatedRoom);
}

이중 저장 전략:

  • MongoDB: 영구 데이터 (게임 히스토리)
  • Redis: 휘발성 데이터 (세션, 락, 캐시)

AI 서비스 연동

HTTP 클라이언트 설계

@Service
public class AiApiService {
    
    public AiResponseResult generateAiResponse(String gameId, String roomId, 
                                             String currentUser, String currentMessage,
                                             List<AiGameMessageDto> contextMessages, 
                                             int turnNumber) {
        
        String url = aiServiceUrl + "/ai-response";
        
        // 요청 데이터 구성
        AiResponseRequest request = AiResponseRequest.builder()
                .gameId(gameId)
                .aiGameRoomId(roomId)
                .currentUser(currentUser)
                .currentMessage(currentMessage)
                .contextMessages(contextMessages.stream()
                        .map(this::convertToContextMessage)
                        .toList())
                .turnNumber(turnNumber)
                .build();

        // Python AI 서비스 호출
        ResponseEntity<Map> response = restTemplate.exchange(
                url, HttpMethod.POST, httpEntity, Map.class);
        
        // 응답 검증 및 결과 반환
        return parseAiResponse(response);
    }
}

비동기 처리 패턴

문제: AI 응답 생성이 최대 60초까지 소요될 수 있음

해결: 이벤트 기반 비동기 처리

@EventListener
@Async("matchingTaskExecutor")
public void handleAiTurnProcessEvent(AiTurnProcessEvent event) {
    processAiTurn(event.getAiGameRoomId(), event.getAiRequest());
}

처리 플로우:
1. 플레이어 메시지 수신 → 즉시 응답 (논블로킹)
2. 백그라운드에서 AI 이벤트 처리
3. AI 응답 완료 → WebSocket으로 결과 브로드캐스트

장애 처리 전략

public AiResponseResult generateAiResponse(...) {
    try {
        // AI 서비스 호출
        return callAiService(request);
        
    } catch (ResourceAccessException e) {
        log.error("AI 서비스 연결 시간 초과: roomId={}", roomId);
        throw new AiChatException(ErrorCode.AI_RESPONSE_TIMEOUT_ERROR, e);
        
    } catch (RestClientException e) {
        log.error("AI 서비스 호출 실패: roomId={}", roomId);
        throw new AiChatException(ErrorCode.AI_RESPONSE_PROCESSING_ERROR, e);
    }
}

에러 시나리오별 대응:

  • 타임아웃: 게임 일시정지 + 에러 메시지 전송
  • 서비스 장애: 폴백 응답 또는 재시도
  • 잘못된 응답: 데이터 검증 후 에러 처리

메시지 순서 보장

MongoDB 인덱싱 전략

핵심 아이디어: messageOrder 필드로 메시지 순서 보장

@Document(collection = "ai_game_messages")  
public class AiGameMessage {
    @Id private String id;
    private String roomId;
    private String content;
    @Indexed private Integer messageOrder;  // 순서 보장용
    private Integer turnNumber;
}

인덱스 설정:

@Configuration
public class AiGameMessageIndexConfig {
    @EventListener(ContextRefreshedEvent.class)
    public void ensureIndexes() {
        mongoTemplate.indexOps(AiGameMessage.class)
            .ensureIndex(Index.on("roomId", Sort.Direction.ASC)
                             .on("messageOrder", Sort.Direction.ASC));
    }
}

메시지 순서 관리

// 턴 시작 메시지: messageOrder = 0
// 플레이어 메시지: messageOrder = 1, 2, 3, ...  
// AI 응답: messageOrder = 5000
// 턴 종료 메시지: messageOrder = 9999
// 에러 메시지: messageOrder = 9998

이렇게 하면 턴별로 메시지가 정확한 순서로 조회됩니다.

컨텍스트 메시지 관리

AI에게 전달할 대화 맥락

문제: AI가 이전 대화 내용을 기억하게 하려면?

해결: 최근 N개 메시지를 컨텍스트로 전달

public List<AiGameMessageDto> getContextMessages(String roomId, int maxCount, int currentTurn) {
    // 현재 턴 이전의 최근 메시지들을 조회
    List<AiGameMessage> messages = aiGameMessageRepository
            .findByAiGameRoomIdAndTurnNumberLessThanOrderByMessageOrderDesc(
                roomId, currentTurn, PageRequest.of(0, maxCount));
    
    // 시간순으로 정렬하여 반환 (AI가 순서대로 읽을 수 있게)
    return messages.stream()
            .sorted(Comparator.comparing(AiGameMessage::getMessageOrder))
            .map(AiGameMessageDto::fromEntity)
            .toList();
}

설정 가능한 컨텍스트 개수:

# application-dev.properties
aichat.context.message-count=5

성능 최적화

1. 비동기 처리

  • 스레드 풀: AI 응답 전용 스레드 풀 분리
  • 이벤트 처리: Spring Events로 논블로킹 처리

2. Redis 활용

  • 세션 캐싱: 게임 상태를 Redis에서 빠르게 조회
  • 분산 락: 동시성 제어로 데이터 무결성 보장

3. MongoDB 최적화

  • 복합 인덱스: roomId + messageOrder로 빠른 메시지 조회
  • TTL 인덱스: 오래된 게임 데이터 자동 삭제

에러 처리 및 복구

장애 상황별 대응

1. AI 서비스 장애

private RsData<AiGameMessageResponse> handleAiResponseError(String roomId, Exception e, String errorMessage) {
    log.error("AI 응답 오류: roomId={}, error={}", roomId, e.getMessage(), e);
    
    // 락 해제
    aiGameStateService.unlockAfterAiResponse(roomId);
    
    // 게임 일시정지  
    aiGameStateService.pauseGame(roomId, "AI 응답 생성 오류");
    
    return RsData.of("500", errorMessage, null);
}

2. 세션 만료

public boolean isSessionValid(String roomId) {
    String sessionKey = AI_GAME_SESSION_PREFIX + roomId;
    return valkeyService.exists(sessionKey);
}

public void extendSession(String roomId) {
    String sessionKey = AI_GAME_SESSION_PREFIX + roomId;
    if (valkeyService.exists(sessionKey)) {
        valkeyService.expire(sessionKey, DEFAULT_SESSION_TIMEOUT_SECONDS);
    }
}

3. WebSocket 연결 끊김

  • 클라이언트 자동 재연결
  • 메시지 히스토리 동기화
  • 게임 상태 복구

핵심 성과 및 배운 점

성공한 설계 결정

1. 이중 데이터 저장 전략

  • MongoDB: 영구 데이터
  • Redis: 휘발성 + 실시간 상태

2. 분산 락을 통한 동시성 제어

  • AI 응답 중 메시지 차단
  • 데이터 무결성 보장

3. 이벤트 기반 비동기 처리

  • 논블로킹 사용자 경험
  • 장시간 작업의 백그라운드 처리

개선이 필요한 부분

1. AI 응답 시간 최적화

  • 현재: 평균 30-60초
  • 목표: 10-20초 단축

2. 에러 복구 자동화

  • 현재: 수동 게임 재시작
  • 목표: 자동 복구 메커니즘

3. 모니터링 강화

  • 응답 시간 메트릭
  • 에러율 추적
  • 사용자 경험 지표

다음 편 예고

3편에서는 실시간 매칭 시스템을 다룰 예정입니다:

  • Redis Queue 기반 매칭 알고리즘
  • 대기 시간 예측 및 최적화
  • WebSocket을 통한 매칭 상태 실시간 업데이트
  • 매칭 취소 및 타임아웃 처리

시리즈 목차
1. 멀티 데이터베이스 아키텍처 설계
2. AI 채팅 시스템 구현 ← 현재
3. 실시간 매칭 시스템 구현 (matching)
4. 통합 룸 시스템 구현 (room)
5. 개발 회고 & 성능 최적화

profile
..

0개의 댓글