Ramble-FE / services / matching / matching.service.ts
matching.service.ts
Raw
import { EventEmitter } from "eventemitter3";
import { Destination, ISignalingClient, Subscription, Topic } from "../signaling";
import { IMatchingService } from "./matching.interface";
import { MatchInfo, MatchingEventMap, MatchRequest, MatchResponse, MatchResponseTemp, MatchStatus } from "./matching.types";

const MATCH_TIMEOUT_MS = 5 * 60000; // 5 분

export class MatchingService extends EventEmitter<MatchingEventMap> implements IMatchingService {
    private isMatching: boolean = false;
    private matchTimeoutId: any = null;
    private matchSubscription?: Subscription;
    private currentMatchHandler?: ((channel: Topic, message: any) => void);

    constructor(private signalingClient: ISignalingClient) {
        super();
    }

    // 매칭 핸들러 생성
    private createMatchHandler(
        resolve: (value: MatchInfo | null) => void,
        reject: (reason?: any) => void
    ): (channel: Topic, message: any) => void {
        return (channel: Topic, payload: any) => {
            if (channel !== Topic.MATCHING) return;

            console.log("[MatchingService] Match response received:", payload);

            const response = this.convertMatchResponse(payload);

            switch (response.status) {
                case MatchStatus.WAITING: // 매칭 중 유지
                    break;
                case MatchStatus.SUCCESS:
                    this.isMatching = false;
                    this.emit("matching:found", response.data!);
                    resolve(response.data!);
                    break;
                case MatchStatus.FAILED:
                    this.isMatching = false;
                    this.emit("matching:failed", response.message);
                    resolve(null);
                    break;
                default:
                    console.error("[MatchingService] Unknown match status:", response.status);
                    const error = new Error("Match request failed");

                    this.isMatching = false;
                    this.emit("matching:error", error);
                    reject(error);
                    break;
            }
        };
    }

    // 임시 메서드. 서버 응답 형식 변경되면 삭제 (서버 응답에 아직 sessionId 등 필수 정보 없음)
    private convertMatchResponse(payload: any): MatchResponse {
        if (payload && 'data' in payload) {
            return payload as MatchResponse;
        }
        
        const temp = payload as MatchResponseTemp;

        // 임시 sessionId 생성  
        const sessionId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
        
        const data: MatchInfo = {
            sessionId: sessionId,
            role: temp.role,
            otherUserId: temp.partnerInfo || ""
        };

        return {
            status: temp.status,
            data: data,
            message: "Match found"
        };
    }

    // 매칭 취소 (리소스 정리)
    private cleanupMatchRequest(): void {
        // timeout 정리
        if (this.matchTimeoutId) {
            clearTimeout(this.matchTimeoutId);
            this.matchTimeoutId = null;
        }

        // message handler 정리
        if (this.currentMatchHandler) {
            this.signalingClient.off("message", this.currentMatchHandler);
            this.currentMatchHandler = undefined;
        }
    }

    // 매칭 취소 (통신 정리)
    private cancelMatch(): void {
        // 취소 요청 전송
        if (this.signalingClient.isConnected()) {
            try {
                this.signalingClient.send(Destination.CANCEL, {});
            } catch (error) {
                console.error("[MatchingService] Error sending cancel request:", error);
            }
        }

        // 구독 정리
        if (this.matchSubscription) {
            try {
                this.matchSubscription.unsubscribe();
            } catch (error) {
                console.error("[MatchingService] Error unsubscribing from match channel:", error);
            } finally {
                this.matchSubscription = undefined;
            }
        }
    }

    // 매칭 요청 메서드
    async requestMatch(params: MatchRequest): Promise<MatchInfo | null> {
        if (this.isMatching) {
            throw new Error("Already requesting a match");
        }

        if (!this.signalingClient.isConnected()) {
            throw new Error("Cannot request match: signaling client not connected");
        }

        this.isMatching = true;

        return new Promise((resolve, reject) => {
            try {
                console.log("[MatchingService] Requesting match with params:", params);

                // 매칭 채널 구독
                this.matchSubscription = this.signalingClient.subscribe(Topic.MATCHING);

                // 매칭 결과 리스너 (구독 결과)
                this.currentMatchHandler = this.createMatchHandler(resolve, reject);
                this.signalingClient.on("message", this.currentMatchHandler);

                // timeout
                this.matchTimeoutId = setTimeout(() => {
                    console.warn("[MatchingService] Match request timed out");
                    this.cleanupMatchRequest();
                    this.cancelMatch();

                    this.isMatching = false;

                    const error = new Error("Match request timed out");
                    this.emit("matching:timeout", error);
                    resolve(null);
                }, MATCH_TIMEOUT_MS);

                // 매칭 요청 전송
                this.signalingClient.send(Destination.MATCHING, params);
            } catch (error: any) {
                console.error("[MatchingService] Failed to request match:", error);
                this.cleanupMatchRequest();
                this.isMatching = false;

                const webSocketError = error instanceof Error
                        ? error
                        : new Error(`Failed to request match: ${error.message}`,);

                this.emit("matching:error", webSocketError);
                reject(webSocketError);
            }
        });
    }

    // 매칭 취소 메서드 (외부)
    abortMatch(): void {
        if (!this.isMatching) {
            return;
        }

        console.log("[MatchingService] Aborting match request");

        this.cleanupMatchRequest();
        this.cancelMatch();

        this.isMatching = false;
    }

}