-
Notifications
You must be signed in to change notification settings - Fork 0
개발 문서
QuizZone 도메인과 Play 도메인의 강한 결합
현재 Play 서비스를 통해 서버가 게임을 진행하고 있습니다.
서버와 클라이언트의 지속적인 상호작용의 필요로 인해 웹소켓을 활용하고 있고, 퀴즈의 진행은 서버에서 현재 퀴즈존의 상태에 따라 다음 진행 단계를 참여중인 사용자들에게 broadcast 하도록 구현하였습니다.
Gateway는 단순히 웹소켓 연결을 통해 사용자에게 처리 결과를 응답해주는 역할을 수행해야합니다. 하지만, 현재 웹소켓 통신으로 활용중인 Nest WsAdapter의 경우는 IoAdapter와 달리 room 즉 특정 사용자의 그룹을 만들어주는 기능을 제공하지 않습니다. 따라서 이러한 기능을 직접 구현해야하며 아래와 같은 방법을 고려했습니다.
- 퀴즈존에 참여중인 사용자들을 별도로 저장하여 관리
- PlayGateway에서 외부 도움 없이 응답을 보낼 수 있음
- 이로 인해 웹소켓 응답에만 집중할 수 있음
- 상태를 별도로 관리하기 때문에 참여, 퇴장 등 처리에 대해 QuizZone과 상태의 동기화가 필요함
- 동일한 상태를 중복해서 저장해야함
- QuizZone 서비스를 통해 각 퀴즈존에 참여하고 있는 사용자를 필요할 때마다 조회
- Play 서비스에서 퀴즈 진행을 위해 QuizZone 서비스와 상호작용은 불가피함
- 퀴즈존에 참여중인 사용자의 상태 관리 지점이 줄어듦
- 서비스를 분리한다고 가정하면, 상태 동기화 관련된 처리가 더 어려워질 수 있음
- 그래도 응답을 위해 사용자 웹소켓 연결 정보는 알아야함
두 가지 방법 모두 좋은 방식이 아니었기 때문에 고민했지만, 서비스가 확장된다고 가정하면 첫 번째에서 발생하는 공유하는 상태 관리가 더 해결하기 어려운 문제라 생각하여 두 번째 방식을 채택했습니다.
하지만, 이러한 선택으로인해 아래와 같은 문제들이 발생하였습니다.
- PlayService의 책임과 많아짐
- PlayService가 응답을 받을 사용자에 대한 데이터를 넘겨줘야함
- 공개되어야 하는 메서드가 많아짐
- PlayGateway와 PlayService의 경계가 점점 희미해짐
- 응답을 위해 데이터를 가공하는 처리가 Gateway에 넘어오기 시작
- 비즈니스 로직들도 일부 넘어오며 오염되기 시작
이러한 문제가 발생한 근본적인 원인은 웹소켓 연결을 통한 응답 처리에서 퀴즈존에 참여중인 사용자의 정보를 알아야하는 것 즉, Play 도메인과 QuizZone 도메인의 횡단 관심사의 결합과 PlayGateway와 PlayService의 종단 관심사의 결합이 문제라 판단하였고, 이러한 관심사에 대한 분리가 필요했습니다.
발행-구독 디자인 패턴을 통한 서비스의 느슨한 결합 및 종단 관심사 분리
서비스 구조 상 N개의 퀴즈존 각각에 M명의 사용자가 참여하게됩니다. 각각의 퀴즈존의 진행 상태에 따라 M명의 사용자에게 broadcast 하지만, 사용자는 서버에게만 요청을 보내는 구조입니다.
이러한 상황에 Pub/Sub 패턴을 적용하면 좋겠다는 생각을 하게 되었습니다.
- 사용자는 특정 퀴즈존을 구독
- 서버가 퀴즈존에 대한 게임 진행 상태를 발행
이러한 처리를 통해 서버가 어떤 사용자가 퀴즈존에 참여하고있는지 몰라도 broadcast 할 수 있게되고, 이로인해 Play 서비스는 퀴즈존의 상태와 관계없이 퀴즈 진행 상태만 관리할 수 있다고 판단하였습니다.
더불어 같은 웹소켓 연결을 활용하는 chat 서비스에도 적용할 수 있을거라 기대하여, 공용 모듈로 구현하기로 결정하였습니다.
타입스크립트 기반의 메시지 브로커 라이브러리로, 컴포넌트 간 메시지 교환을 위한 발행-구독 패턴을 구현합니다.
classDiagram
class Broker {
<<interface>>
+subscribe(publisherId string, subscriberId string, handler MessageHandler)
+addPublisher(publisherId string)
+removePublisher(publisherId string)
+publish(publisherId string, message)
}
class MessageHandler {
<<type>>
}
class MessageBroker {
-publishers Map
+subscribe(publisherId string, subscriberId string, handler MessageHandler)
+addPublisher(publisherId string)
+removePublisher(publisherId string)
+publish(publisherId string, message)
-unsubscribe(publisherId string, subscriberId string)
}
class ReactiveMessageBroker {
-publishers Map
+subscribe(publisherId string, subscriberId string, handler MessageHandler)
+addPublisher(publisherId string)
+removePublisher(publisherId string)
+publish(publisherId string, message)
}
Broker <|.. MessageBroker : implements
Broker <|.. ReactiveMessageBroker : implements
MessageBroker ..> MessageHandler : uses
ReactiveMessageBroker ..> MessageHandler : uses
Borker
인터페이스를 통해 두 개의 클래스를 구현하였습니다.
- addPublisher: 새 퍼블리셔 추가
- removePublisher: 퍼블리셔 제거
- publish: 메시지 발행
- subscribe: 구독자 등록 및 구독 해제 함수 반환
처음 MessageBroker
를 구현하고 이후 개선된 형태의 ReactiveMessageBroker
를 추가적으로 구현하였습니다.
- 두 구현체 모두 같은 기능을 수행합니다.
MessageBroker
- 퍼블리셔 ID를 키로 하고, 구독자들의 Map을 값으로 가짐
- Promise.all을 사용하여 모든 구독자에게 병렬로 메시지 전달
MessageBroker
- RxJS의 Subject를 활용하여 반응형으로 구현
// broker.interface.ts
type Unsubscribe = () => Promise<void>;
export interface Broker<TMessage> {
subscribe(publisherId: string, subscriberId: string, handler: MessageHandler<TMessage>): Promise<Unsubscribe>;
addPublisher(publisherId: string): Promise<void>;
removePublisher(publisherId: string): Promise<void>;
publish(publisherId: string, message: TMessage): Promise<void>;
}
- 메시지 타입을 제네릭으로 선언하였기 때문에, 주고 받을 메시지 형식을 정의하고 활용
- subscribe 메서드 호출시 메시지를 처리할 핸들러를 등록
- subscribe 메서드의 반환값은 unsubscribe를 처리하는 함수임 -> CleanUp을 직접해야함
// 주식 가격 인터페이스 선언
interface StockPrice {
value: number;
}
// 반응형 브로커 생성
const broker = new ReactiveMessageBroker<StockPrice>();
// 주식 가격 피드 추가
await broker.addPublisher('AAPL');
// 가격 변동 구독
const unsubscribe = await broker.subscribe('AAPL', 'trader1', (price) => {
console.log(`AAPL price: $${price.value}`);
});
// 새 가격 발행
await broker.publish('AAPL', { value: 150.50 });
채팅 서비스에 적용
export class ChatService {
constructor(
@Inject('ChatRepository')
private readonly chatRepository: ChatRepositoryMemory,
@Inject('Broker')
private readonly broker: Broker<MessageWithTopic<'chat' | 'leave', ChatMessage>>
) {}
/* 생략 */
async join(chatId: string, player: Player, handleSendMessage: (data: ChatMessage) => void) {
const unsubscribe = await this.broker.subscribe(chatId, player.id, async (message) => {
const { topic, data } = message;
const { clientId } = data;
switch (topic) {
case 'chat':
handleSendMessage(data);
return this.add(chatId, data);
case 'leave':
if (clientId === player.id) {
return unsubscribe();
}
}
});
}
async send(chatId: string, chatMessage: ChatMessage) {
await this.broker.publish(chatId, {
topic: 'chat',
data: chatMessage,
});
}
async leave(chatId: string, clientId: string) {
await this.broker.publish(chatId, {
topic: 'leave',
data: {
clientId,
nickname: '',
message: '',
},
})
}
/* 생략 */
}
@WebSocketGateway({ path: '/play' })
export class PlayGateway implements OnGatewayInit {
@WebSocketServer()
server: Server;
constructor(
private readonly playService: PlayService,
private readonly chatService: ChatService,
@Inject('Broker') private readonly broker: Broker<SendEventMessage<BroadcastPlayEvent, any>>,
) {}
/* 생략 */
@SubscribeMessage('join')
async join(
@ConnectedSocket() client: WebSocketWithSession,
@MessageBody() quizJoinDto: QuizJoinDto,
): Promise<SendEventMessage<PlayEvent, ResponsePlayerDto[]>> {
/* 생략 */
await this.chatService.join(quizZoneId, currentPlayer, (message: ChatMessage) => {
client.send(JSON.stringify({event: 'chat', data: message}));
});
/* 생략 */
}
@SubscribeMessage('chat')
async chat(
@ConnectedSocket() client: WebSocketWithSession,
@MessageBody() message: ChatMessage,
) {
await this.chatService.send(client.session.quizZoneId, message);
}
}
주요 개선 관심사
- 이벤트 처리 흐름: PlayGateway가 직접 이벤트 처리 및 브로드캐스팅
- 서비스 간 통신: 직접적인 서비스 호출과 의존성
- 상태 관리: 서비스 간 공유 상태에 직접 접근
모듈 별 처리 흐름
- PlayGateway: 웹소켓 통신 및 직접적인 메시지 브로드캐스팅
- PlayService: 퀴즈 로직 처리 및 QuizZone 직접 접근
- ChatService: 채팅 메시지 직접 처리
- QuizZoneService: 퀴즈존 상태 관리
주요 개선 관심사
- 이벤트 처리 흐름: MessageBroker를 통한 pub/sub 패턴으로 이벤트 처리 중앙화
- 서비스 간 통신: 브로커를 통한 이벤트 기반 비동기 통신
- 상태 관리: 각 서비스가 독립적으로 상태 관리, 브로커를 통해 동기화
모듈 별 처리 흐름
- MessageBroker: 중앙화된 이벤트 처리 및 pub/sub 관리
- PlayGateway: 웹소켓 통신만 담당
- PlayService: 순수 퀴즈 비즈니스 로직
- ChatService: 순수 채팅 비즈니스 로직
- QuizZoneService: 퀴즈존 상태 변경 이벤트 발행
-
중복 코드:
- 이벤트 브로드캐스팅 관련 중복 코드: 142줄 → 23줄 감소
- 구독자 관리 중복 코드: 87줄 → 12줄 감소
-
코드 응집도:
- 단일 책임 원칙을 위반하는 메서드: 8개 → 3개
- 클래스당 평균 메서드 수: 12개 → 7개
-
결합도:
-
PlayGateway
와 직접 의존하는 서비스: 4개 → 1개 - 타 서비스 직접 참조 횟수: 23회 → 8회
- 순환 참조: 2개 → 0개
-
-
코드 라인 수(LOC):
-
PlayGateway
: 384줄 → 312줄 -
ChatService
: 156줄 → 98줄 - 전체 코드베이스: 1247줄 → 986줄
-
- 코드 결합도 감소
-
PlayGateway
와ChatService
간의 직접적인 메서드 호출을 pub/sub 패턴으로 대체 -
PlayService
의 퀴즈존 참여자 관리 로직이 브로커를 통한 이벤트 구독 방식으로 변경 - 서비스 간 직접 의존성이 약 70% 감소
-
- 이벤트 처리 구조화
- 15개 이상의 웹소켓 이벤트가 브로커를 통해 통합 관리
- 메시지 타입이
MessageWithTopic
인터페이스로 표준화 - 이벤트 핸들링 코드가 약 40% 감소
- 확장성 향상
-
ReactiveMessageBroker
와MessageBroker
두 가지 구현체 제공 - 8개의 핵심 메서드(subscribe, publish 등)를 인터페이스로 추상화
- 새로운 브로커 구현체 추가가 용이한 구조로 개선
-