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(...);
통합된 룸 관리 인터페이스 제공
┌─────────────────────────────────────────────────────────────┐
│ 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 - 채팅룸 어댑터
핵심 아이디어: 룸 타입에 따라 적절한 서비스를 자동으로 선택
@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에서 자동 등록 → 코드 수정 최소화
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);
}
문제: 기존 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);
}
}
@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
일관된 응답 처리:
@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);
}
}
}
장점:
@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;
}
}
public class RoomServiceFactory {
private void initializeServiceMap() {
if (serviceMap == null) { // 첫 요청 시에만 초기화
serviceMap = roomServices.stream()
.collect(Collectors.toMap(
RoomService::getSupportedRoomType,
Function.identity()
));
}
}
}
// 팩토리 상태 정보 캐싱
@Cacheable("factory-status")
public String getFactoryStatus() {
return roomServiceFactory.getFactoryStatus();
}
새로운 룸 타입 추가 절차:
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. 부분 실패 처리
API 엔드포인트 수:
매칭 시스템 코드 복잡도:
새 룸 타입 추가 비용:
4편까지의 여정 정리:
1편: 멀티 데이터베이스 아키텍처 → 기반 설계
2편: AI 채팅 시스템 → 실시간 게임 로직
3편: 실시간 매칭 시스템 → 공정한 플레이어 매칭
4편: 통합 룸 시스템 → 확장 가능한 통합 관리
전체 시스템의 시너지:
다음 5편 예고: 개발 회고 & 성능 최적화
시리즈 목차
1. 멀티 데이터베이스 아키텍처 설계
2. AI 채팅 시스템 구현 (aichat)
3. 실시간 매칭 시스템 구현 (matching)
4. 통합 룸 시스템 구현 ← 현재
5. 개발 회고 & 성능 최적화