실시간 턴제 AI 게임 채팅을 구현하며 마주한 기술적 도전들
1편에서 멀티 데이터베이스 아키텍처를 살펴봤다면, 이번 편에서는 AI 채팅 시스템의 구체적인 구현을 다뤄보겠습니다. 실시간 WebSocket 통신과 외부 AI 서비스 연동, 그리고 턴제 게임 상태 관리까지 - 생각보다 복잡했던 여정을 공유합니다.
1. 턴제 게임 로직
2. 실시간 통신
3. 외부 서비스 연동
aichat/
├── controller/ - REST API & WebSocket 컨트롤러
├── service/ - 비즈니스 로직 처리
├── entity/ - MongoDB 엔티티
├── dto/ - 데이터 전송 객체
├── common/ - 열거형 및 상수
└── event/ - 비동기 이벤트 처리
컨트롤러 레이어
AiGameRoomController: 게임방 CRUD APIAiChatStompController: 실시간 메시징 처리서비스 레이어
AiGameFlowService: AI 턴 플로우 관리AiGameStateService: 게임 상태 및 세션 관리AiApiService: 외부 AI 서비스 연동@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에 저장하는 것입니다.
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);
}
이중 저장 전략:
@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);
}
}
에러 시나리오별 대응:
핵심 아이디어: 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가 이전 대화 내용을 기억하게 하려면?
해결: 최근 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 서비스 장애
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. 이중 데이터 저장 전략
2. 분산 락을 통한 동시성 제어
3. 이벤트 기반 비동기 처리
1. AI 응답 시간 최적화
2. 에러 복구 자동화
3. 모니터링 강화

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