DungeonTalk 백엔드 개발기 - 4편: 통합 룸 시스템 구현

MJ·2025년 8월 14일
post-thumbnail

Factory와 Adapter 패턴으로 AI 게임룸과 채팅룸을 하나로 통합하다

들어가며

매칭 시스템으로 플레이어들을 모았다면, 이제 실제 게임이 진행될 공간을 만들어야 합니다. DungeonTalk에서는 AI 게임룸과 일반 채팅룸, 두 가지 서로 다른 룸이 동시에 필요합니다.

하지만 각각 다른 서비스로 개발된 상황에서 일관된 인터페이스로 관리하고 싶었습니다. 이번 편에서는 Factory 패턴과 Adapter 패턴을 활용해 통합 룸 시스템을 구현한 과정을 공유합니다.

문제 상황과 해결 목표

기존 문제점

1. 중복된 룸 관리 로직

AI 게임룸 생성 → AiGameRoomService.createAiGameRoom()
채팅룸 생성 → ChatRoomService.createRoom()

2. 일관성 없는 API

GET /v1/aichat/rooms/{roomId}      // AI 게임룸
GET /v1/chat/rooms/{roomId}        // 채팅룸  

3. 매칭 시스템의 복잡성

// 매칭 완료 시 두 개의 서로 다른 서비스 호출
AiGameRoomResponse aiRoom = aiGameRoomService.createAiGameRoom(...);
ChatRoomDto chatRoom = chatRoomService.createRoom(...);

해결 목표

통합된 룸 관리 인터페이스 제공

  • 단일 API로 모든 룸 타입 관리
  • 확장 가능한 아키텍처
  • 기존 서비스와의 호환성 보장

통합 룸 시스템 아키텍처

아키텍처 다이어그램

┌─────────────────────────────────────────────────────────────┐
│                    Client Layer                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌──────────────┐ │
│  │  Web Client     │  │  Mobile App     │  │  Admin Panel │ │
│  └─────────────────┘  └─────────────────┘  └──────────────┘ │
└─────────────────┬───────────────┬───────────────┬───────────┘
                  │               │               │
                  ▼               ▼               ▼
┌─────────────────────────────────────────────────────────────┐
│                 Controller Layer                            │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │            UnifiedRoomController                        │ │
│  │  - POST /v1/rooms (통합 룸 생성)                          │ │
│  │  - GET /v1/rooms/{type}/{id} (룸 조회)                   │ │
│  │  └─────────────────────────────────────────────────────┘ │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 Service Layer                               │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │              UnifiedRoomService                         │ │
│  │          (비즈니스 로직 & 에러 처리)                        │ │
│  └─────────────────┬───────────────────────────────────────┘ │
│                    │                                         │
│                    ▼                                         │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │             RoomServiceFactory                          │ │
│  │              (Factory Pattern)                          │ │
│  └─────────┬───────────────────────────┬───────────────────┘ │
└───────────┼───────────────────────────┼─────────────────────┘
            │                           │
            ▼                           ▼
┌─────────────────────┐       ┌─────────────────────┐
│   Adapter Layer     │       │   Adapter Layer     │
│ ┌─────────────────┐ │       │ ┌─────────────────┐ │
│ │AiGameRoomService│ │       │ │ChatRoomService  │ │
│ │    Adapter      │ │       │ │    Adapter      │ │
│ │(Adapter Pattern)│ │       │ │(Adapter Pattern)│ │
│ └─────────────────┘ │       │ └─────────────────┘ │
└─────────┬───────────┘       └─────────┬───────────┘
          │                             │
          ▼                             ▼
┌─────────────────────┐       ┌─────────────────────┐
│   Domain Services   │       │   Domain Services   │
│ ┌─────────────────┐ │       │ ┌─────────────────┐ │
│ │AiGameRoomService│ │       │ │ChatRoomService  │ │
│ │AiGameMessage    │ │       │ │ChatMessage      │ │
│ │     Service     │ │       │ │    Service      │ │
│ └─────────────────┘ │       │ └─────────────────┘ │
└─────────┬───────────┘       └─────────┬───────────┘
          │                             │
          ▼                             ▼
┌─────────────────────┐       ┌─────────────────────┐
│   Data Layer        │       │   Data Layer        │
│ ┌─────────────────┐ │       │ ┌─────────────────┐ │
│ │    MongoDB      │ │       │ │    MongoDB      │ │
│ │  (AI Game Data) │ │       │ │  (Chat Data)    │ │
│ └─────────────────┘ │       │ └─────────────────┘ │
└─────────────────────┘       └─────────────────────┘

핵심 컴포넌트 구조

통합 룸 시스템
├── UnifiedRoomController      - 통합 REST API 제공
├── UnifiedRoomService        - 비즈니스 로직 & 응답 처리
├── RoomServiceFactory        - 룸 타입별 서비스 라우팅
├── RoomService (Interface)   - 통합 인터페이스 정의
├── AiGameRoomServiceAdapter  - AI 게임룸 어댑터
└── ChatRoomServiceAdapter    - 채팅룸 어댑터

Factory 패턴 구현

RoomServiceFactory 설계

핵심 아이디어: 룸 타입에 따라 적절한 서비스를 자동으로 선택

@Component
public class RoomServiceFactory {
    
    private final List<RoomService> roomServices;
    private Map<RoomType, RoomService> serviceMap;
    
    public RoomService getService(RoomType roomType) {
        initializeServiceMap();
        
        RoomService service = serviceMap.get(roomType);
        if (service == null) {
            throw new IllegalArgumentException(
                String.format("지원하지 않는 룸 타입: %s", roomType.getDisplayName())
            );
        }
        
        return service;
    }
    
    private void initializeServiceMap() {
        if (serviceMap == null) {
            serviceMap = roomServices.stream()
                .collect(Collectors.toMap(
                    RoomService::getSupportedRoomType,
                    Function.identity()
                ));
        }
    }
}

동작 과정:
1. Spring이 모든 RoomService 구현체를 주입
2. 각 서비스의 getSupportedRoomType() 기반으로 맵 구성
3. 요청 시 룸 타입에 맞는 서비스를 즉시 반환

확장 가능한 룸 타입 시스템

public enum RoomType {
    AI_GAME("ai-game", "AI 게임룸"),
    PLAYER_CHAT("player-chat", "플레이어 채팅룸");
    
    public static RoomType fromCode(String code) {
        return Arrays.stream(values())
            .filter(type -> type.code.equals(code))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("알 수 없는 룸 타입: " + code));
    }
}

새 룸 타입 추가 시:
1. RoomType 열거형에 새 타입 추가
2. RoomService 구현체 작성
3. Spring에서 자동 등록 → 코드 수정 최소화

Adapter 패턴 구현

통합 인터페이스 정의

public interface RoomService {
    
    RoomType getSupportedRoomType();
    
    // 룸 관리
    UnifiedRoomResponse createRoom(UnifiedRoomRequest request);
    UnifiedRoomResponse getRoom(String roomId);
    void deleteRoom(String roomId);
    List<UnifiedRoomResponse> getAvailableRooms();
    
    // 참여자 관리  
    UnifiedRoomResponse joinRoom(String roomId, String memberId);
    UnifiedRoomResponse leaveRoom(String roomId, String memberId);
    List<UnifiedRoomResponse> getUserRooms(String memberId);
    
    // 메시지 처리
    void processMessage(UnifiedMessageRequest request);
    void sendSystemMessage(String roomId, String message);
    
    // 상태 확인
    boolean existsRoom(String roomId);
    boolean isRoomActive(String roomId);
    boolean canJoinRoom(String roomId, String memberId);
}

AI 게임룸 어댑터

문제: 기존 AiGameRoomService를 건드리지 않고 통합 인터페이스 지원

해결: Adapter 패턴으로 인터페이스 변환

@Service
public class AiGameRoomServiceAdapter implements RoomService {
    
    private final AiGameRoomService aiGameRoomService;
    
    @Override
    public RoomType getSupportedRoomType() {
        return RoomType.AI_GAME;
    }
    
    @Override
    public UnifiedRoomResponse createRoom(UnifiedRoomRequest request) {
        // UnifiedRoomRequest → AiGameRoomCreateRequest 변환
        AiGameRoomCreateRequest aiRequest = AiGameRoomCreateRequest.builder()
                .gameId(request.getGameId())
                .roomName(request.getRoomName())
                .maxParticipants(request.getMaxParticipants())
                .creatorId(request.getCreatorId())
                .build();
        
        // 기존 서비스 호출
        AiGameRoomResponse aiResponse = aiGameRoomService.createAiGameRoom(aiRequest);
        
        // AiGameRoomResponse → UnifiedRoomResponse 변환
        return UnifiedRoomResponse.fromAiGameRoom(aiResponse);
    }
}

채팅룸 어댑터

@Service  
public class ChatRoomServiceAdapter implements RoomService {
    
    private final ChatRoomService chatRoomService;
    
    @Override
    public RoomType getSupportedRoomType() {
        return RoomType.PLAYER_CHAT;
    }
    
    @Override
    public UnifiedRoomResponse createRoom(UnifiedRoomRequest request) {
        // UnifiedRoomRequest → ChatRoomCreateRequestDto 변환
        ChatRoomCreateRequestDto chatRequest = ChatRoomCreateRequestDto.builder()
            .roomName(request.getRoomName())
            .mode(request.getChatMode())
            .maxCapacity(request.getMaxCapacity())
            .build();
        
        // 기존 서비스 호출
        ChatRoomDto room = chatRoomService.createRoom(chatRequest);
        
        // ChatRoomDto → UnifiedRoomResponse 변환
        return UnifiedRoomResponse.fromPlayerChatRoom(room);
    }
}

통합 API 설계

단일 컨트롤러로 모든 룸 타입 지원

@RestController
@RequestMapping("/v1/rooms")
public class UnifiedRoomController {
    
    private final UnifiedRoomService unifiedRoomService;
    
    @PostMapping
    public RsData<UnifiedRoomResponse> createRoom(@Valid @RequestBody UnifiedRoomRequest request) {
        return unifiedRoomService.createRoom(request);
    }
    
    @GetMapping("/{roomType}/{roomId}")
    public RsData<UnifiedRoomResponse> getRoom(@PathVariable String roomType, @PathVariable String roomId) {
        return unifiedRoomService.getRoom(roomType, roomId);
    }
    
    @PostMapping("/{roomType}/{roomId}/join")  
    public RsData<UnifiedRoomResponse> joinRoom(@PathVariable String roomType, @PathVariable String roomId, @RequestBody RoomMemberRequest request) {
        return unifiedRoomService.joinRoom(roomType, roomId, request.getMemberId());
    }
}

API 예시:

# AI 게임룸 생성
POST /v1/rooms
{
  "roomType": "AI_GAME",
  "roomName": "판타지 모험",
  "gameSettings": "중세 판타지 세계관"
}

# 채팅룸 생성  
POST /v1/rooms
{
  "roomType": "PLAYER_CHAT", 
  "roomName": "자유 채팅",
  "chatMode": "MULTI"
}

# 통합 룸 조회
GET /v1/rooms/ai-game/room-12345
GET /v1/rooms/player-chat/room-67890

ResponseService 패턴

일관된 응답 처리:

@Service
public class UnifiedRoomService {
    
    public RsData<UnifiedRoomResponse> createRoom(UnifiedRoomRequest request) {
        try {
            // 요청 유효성 검증
            request.validateByRoomType();
            
            // Factory에서 서비스 선택
            RoomService roomService = roomServiceFactory.getService(request.getRoomType());
            
            // 룸 생성
            UnifiedRoomResponse response = roomService.createRoom(request);
            
            return RsData.of("200", "룸 생성 성공", response);
            
        } catch (IllegalArgumentException e) {
            return RsData.of("400", "잘못된 요청: " + e.getMessage(), null);
        } catch (Exception e) {
            return RsData.of("500", "룸 생성 중 오류가 발생했습니다", null);
        }
    }
}

장점:

  • 컨트롤러는 단순 위임만 처리
  • 비즈니스 로직과 에러 처리를 서비스에서 담당
  • 일관된 응답 형식 보장

DTO 변환 및 매핑

통합 요청/응답 객체

@Data
@Builder
public class UnifiedRoomRequest {
    private RoomType roomType;
    private String roomName;
    private String creatorId;
    private List<String> participantIds;
    
    // AI 게임룸 전용 필드
    private String gameId;
    private String gameSettings;
    private Integer maxParticipants;
    
    // 채팅룸 전용 필드  
    private ChatMode chatMode;
    private Integer maxCapacity;
    
    // 룸 타입별 유효성 검증
    public void validateByRoomType() {
        switch (roomType) {
            case AI_GAME:
                if (gameSettings == null) {
                    throw new IllegalArgumentException("AI 게임룸은 gameSettings가 필요합니다");
                }
                break;
            case PLAYER_CHAT:
                if (chatMode == null) {
                    throw new IllegalArgumentException("채팅룸은 chatMode가 필요합니다");
                }
                break;
        }
    }
}

응답 객체 변환

@Data
@Builder  
public class UnifiedRoomResponse {
    private String roomId;
    private RoomType roomType;
    private String roomName;
    private Integer currentParticipantCount;
    private Integer maxParticipantCount;
    
    // AI 게임룸 → 통합 응답 변환
    public static UnifiedRoomResponse fromAiGameRoom(AiGameRoomResponse aiRoom) {
        return UnifiedRoomResponse.builder()
                .roomId(aiRoom.getId())
                .roomType(RoomType.AI_GAME)
                .roomName(aiRoom.getRoomName())
                .currentParticipantCount(aiRoom.getCurrentParticipantCount())
                .maxParticipantCount(aiRoom.getMaxParticipants())
                .build();
    }
    
    // 채팅룸 → 통합 응답 변환
    public static UnifiedRoomResponse fromPlayerChatRoom(ChatRoomDto chatRoom) {
        return UnifiedRoomResponse.builder()
                .roomId(chatRoom.getId())
                .roomType(RoomType.PLAYER_CHAT) 
                .roomName(chatRoom.getRoomName())
                .currentParticipantCount(0) // 채팅룸은 현재 참여자 수 미지원
                .maxParticipantCount(Integer.MAX_VALUE)
                .build();
    }
}

매칭 시스템과의 통합

통합 룸 생성으로 매칭 간소화

Before (매칭 시스템의 기존 코드):

// 각각 다른 서비스 호출로 복잡함
AiGameRoomResponse aiRoom = aiGameRoomService.createAiGameRoom(aiRequest);
ChatRoomDto chatRoom = chatRoomService.createRoom(chatRequest);

After (통합 룸 시스템 활용):

public Map<String, String> createUnifiedRooms(String gameSessionId, List<String> participants, WorldType worldType) {
    Map<String, String> roomIds = new HashMap<>();
    
    // AI 게임룸 생성  
    UnifiedRoomRequest aiRoomRequest = UnifiedRoomRequest.builder()
            .roomType(RoomType.AI_GAME)
            .roomName(worldType.getDisplayName() + " 랜덤 매칭")
            .creatorId(participants.get(0))
            .participantIds(participants)
            .gameId(gameSessionId)
            .gameSettings(worldType.getGameSettings())
            .build();
    
    UnifiedRoomResponse aiRoom = roomServiceFactory.getService(RoomType.AI_GAME)
                                                  .createRoom(aiRoomRequest);
    roomIds.put("ai", aiRoom.getRoomId());
    
    // 플레이어 채팅룸 생성
    UnifiedRoomRequest chatRoomRequest = UnifiedRoomRequest.builder()
            .roomType(RoomType.PLAYER_CHAT)
            .roomName(worldType.getDisplayName() + " 채팅방")
            .creatorId(participants.get(0))
            .participantIds(participants)
            .chatMode(ChatMode.MULTI)
            .build();
    
    UnifiedRoomResponse chatRoom = roomServiceFactory.getService(RoomType.PLAYER_CHAT)
                                                    .createRoom(chatRoomRequest);
    roomIds.put("chat", chatRoom.getRoomId());
    
    return roomIds;
}

개선 효과:

  • 일관된 룸 생성 로직
  • 새로운 룸 타입 추가 시 변경 최소화
  • 에러 처리 통합

메시지 처리 통합

통합 메시지 인터페이스

public interface RoomService {
    void processMessage(UnifiedMessageRequest request);
    void sendSystemMessage(String roomId, String message);
}

메시지 타입 변환

AI 게임룸 어댑터:

@Override
public void processMessage(UnifiedMessageRequest request) {
    // UnifiedMessageRequest → AiGameMessageSendRequest 변환
    AiGameMessageSendRequest aiRequest = AiGameMessageSendRequest.builder()
            .aiGameRoomId(request.getRoomId())
            .senderId(request.getSenderId())
            .content(request.getContent())
            .messageType(mapToAiMessageType(request.getMessageType()))
            .build();
    
    // 욕설 필터링이 포함된 메시지 처리
    aiGameMessageService.handleWebSocketMessage(aiRequest);
}

private AiMessageType mapToAiMessageType(UnifiedMessageType unifiedType) {
    switch (unifiedType) {
        case USER: return AiMessageType.USER;
        case SYSTEM: return AiMessageType.SYSTEM;
        case AI_RESPONSE: return AiMessageType.AI;
        default: return AiMessageType.USER;
    }
}

채팅룸 어댑터:

@Override  
public void processMessage(UnifiedMessageRequest request) {
    // UnifiedMessageRequest → ChatMessageSendRequestDto 변환
    ChatMessageSendRequestDto chatRequest = ChatMessageSendRequestDto.builder()
            .roomId(request.getRoomId())
            .senderId(request.getSenderId())
            .content(request.getContent())
            .type(mapToChatMessageType(request.getMessageType()))
            .build();
    
    chatMessageService.processMessage(chatRequest);
}

private MessageType mapToChatMessageType(UnifiedMessageType unifiedType) {
    switch (unifiedType) {
        case USER:
        case SYSTEM:
        case OTHER_PLAYER:
            return MessageType.TALK;
        case PRESENCE:
            return MessageType.PRESENCE;
        default:
            return MessageType.TALK;
    }
}

성능 및 확장성

1. 지연 초기화 (Lazy Initialization)

public class RoomServiceFactory {
    private void initializeServiceMap() {
        if (serviceMap == null) {  // 첫 요청 시에만 초기화
            serviceMap = roomServices.stream()
                .collect(Collectors.toMap(
                    RoomService::getSupportedRoomType,
                    Function.identity()
                ));
        }
    }
}

2. 캐싱 전략

// 팩토리 상태 정보 캐싱
@Cacheable("factory-status")
public String getFactoryStatus() {
    return roomServiceFactory.getFactoryStatus();
}

3. 확장성 보장

새로운 룸 타입 추가 절차:
1. RoomType 열거형에 새 타입 추가
2. RoomService 인터페이스 구현
3. 기존 어댑터 참고해서 새 어댑터 작성
4. Spring에서 자동 등록 → 추가 설정 불필요

에러 처리 및 안정성

통합 에러 처리

public RsData<UnifiedRoomResponse> createRoom(UnifiedRoomRequest request) {
    try {
        request.validateByRoomType();
        RoomService roomService = roomServiceFactory.getService(request.getRoomType());
        UnifiedRoomResponse response = roomService.createRoom(request);
        return RsData.of("200", "룸 생성 성공", response);
        
    } catch (IllegalArgumentException e) {
        log.warn("룸 생성 실패 - 잘못된 요청: {}", e.getMessage());
        return RsData.of("400", "잘못된 요청: " + e.getMessage(), null);
    } catch (Exception e) {
        log.error("룸 생성 중 오류 발생: roomType={}", request.getRoomType(), e);
        return RsData.of("500", "룸 생성 중 오류가 발생했습니다", null);
    }
}

서비스별 장애 격리

public RsData<Map<String, List<UnifiedRoomResponse>>> getAvailableRooms() {
    Map<String, List<UnifiedRoomResponse>> availableRooms = roomServiceFactory
            .getSupportedRoomTypes()
            .stream()
            .collect(Collectors.toMap(
                    RoomType::getCode,
                    roomType -> {
                        try {
                            RoomService service = roomServiceFactory.getService(roomType);
                            return service.getAvailableRooms();
                        } catch (Exception e) {
                            log.warn("룸 타입 {} 조회 중 오류: {}", roomType, e.getMessage());
                            return List.of(); // 해당 타입만 실패, 다른 타입은 정상 처리
                        }
                    }
            ));
    
    return RsData.of("200", "입장 가능한 룸 목록 조회 성공", availableRooms);
}

핵심 성과 및 배운 점

성공한 설계 결정

1. Factory 패턴의 효과

  • 룸 타입 추가 시 설정 변경 불필요
  • 런타임에 자동 서비스 발견
  • 코드 중복 제거

2. Adapter 패턴의 효과

  • 기존 서비스 코드 수정 없음
  • 점진적 마이그레이션 가능
  • 인터페이스 통합으로 일관성 확보

3. ResponseService 패턴

  • 컨트롤러 단순화
  • 일관된 에러 처리
  • 비즈니스 로직 집중

개선이 필요한 부분

1. DTO 변환 오버헤드

  • 현재: 매번 객체 변환 발생
  • 목표: 변환 캐싱 또는 직접 매핑

2. 타입 안전성

  • 현재: 문자열 기반 룸 타입 코드
  • 목표: 컴파일 타임 타입 체크 강화

3. 부분 실패 처리

  • 현재: 개별 서비스 실패 시 빈 리스트 반환
  • 목표: 서킷 브레이커 패턴 적용

통합 효과 측정

Before vs After 비교

API 엔드포인트 수:

  • Before: 16개 (AI 룸 8개 + 채팅룸 8개)
  • After: 8개 (통합 API)
  • 50% 감소

매칭 시스템 코드 복잡도:

  • Before: 2개 서비스 × 다른 인터페이스 = 복잡한 분기 처리
  • After: 1개 팩토리 × 통합 인터페이스 = 단순한 룸 생성
  • 코드 라인 60% 감소

새 룸 타입 추가 비용:

  • Before: 컨트롤러, 서비스, API 문서 모두 추가
  • After: 어댑터 1개만 추가
  • 개발 시간 70% 단축

시리즈 마무리

4편까지의 여정 정리:

1편: 멀티 데이터베이스 아키텍처 → 기반 설계
2편: AI 채팅 시스템 → 실시간 게임 로직
3편: 실시간 매칭 시스템 → 공정한 플레이어 매칭
4편: 통합 룸 시스템 → 확장 가능한 통합 관리

전체 시스템의 시너지:

  • 매칭 시스템이 통합 룸 시스템을 활용해 룸 생성
  • 통합 룸 시스템이 AI 채팅 시스템과 연계해 게임 진행
  • 멀티 데이터베이스가 각 도메인의 데이터 특성을 최적 지원

다음 5편 예고: 개발 회고 & 성능 최적화

  • 전체 시스템 성능 분석 및 최적화 사례
  • 개발 과정에서 겪은 시행착오와 해결책
  • 실제 서비스 런칭을 위한 운영 고려사항
  • 팀 협업과 코드 품질 관리 경험

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

profile
..

0개의 댓글