perf(play): 음성접속과 곡 해석 병렬화 + 첫 곡 yt-dlp 예열로 첫 재생 단축

기존: player_play 가 channelJoin() 완료 후에야 search() 를 시작(직렬)하고,
첫 곡은 playNext 시점에 yt-dlp 리졸브(콜드 3.6~4.2s)를 그제서야 수행했다.

- LavalinkManager.search 를 resolveQuery(해석) + addResolved(큐 반영)로 분리.
  resolveQuery 는 플레이어가 필요 없어 음성접속과 동시에 실행 가능.
- resolveQuery 가 해석 직후 prewarmTrack() 으로 첫 곡 오디오 리졸브를 즉시 예열.
  GuildPlayer.resolvePlayable 과 동일한 target 계산식을 써 캐시가 확실히 적중하게 함.
- RedisClient player_play/player_playlist: 해석을 먼저 출발시키고 음성접속과 병렬 진행.
- 구간 계측 로그 추가(음성접속/해석대기/오디오 리졸브 대기).

검증: tsc 통과. 실측 A/B(동일 조건, 미캐시 곡):
  기존 직렬 접속2.00s+리졸브3.03s = 5.03s → 신규 병렬 2.00s+0.97s = 2.98s (약 2s 단축).
This commit is contained in:
2026-09-17 13:11:11 +09:00
parent 1bb1d10b25
commit 6de959be43
3 changed files with 98 additions and 32 deletions

View File

@@ -73,16 +73,29 @@ class RedisClientClass {
const guild = await getGuildById(data.serverId);
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 곡 해석(검색+Lavalink+yt-dlp 예열)은 음성접속과 무관하므로 먼저 출발시켜 둘을 겹친다.
const t0 = Date.now();
const resolving = lavalinkManager.resolveQuery(data.track.url)
.catch((e) => { Logger.warn(`[player_play] 곡 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
if (!voiceChannel) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolving;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
const tJoined = Date.now();
if (!player) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolved = await resolving;
const tResolved = Date.now();
if (!resolved) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolved, data.userId);
Logger.log(`[player_play] 음성접속 ${tJoined - t0}ms / 곡해석 대기 ${tResolved - tJoined}ms / 큐반영까지 ${Date.now() - t0}ms`);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
}
if (data.action === "player_playlist") {
@@ -92,16 +105,25 @@ class RedisClientClass {
const guild = await getGuildById(data.serverId);
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 재생목록 해석도 음성접속과 병렬로 진행한다.
const resolvingPl = lavalinkManager.resolveQuery(data.playlistUrl)
.catch((e) => { Logger.warn(`[player_playlist] 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
if (!voiceChannel) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolvingPl;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolvedPl = await resolvingPl;
if (!resolvedPl) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolvedPl, data.userId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
}
if (data.action === "player_now") {