Skip to content

Commit abd85d8

Browse files
committed
feat: WebSocket 연결 상태 관리 및 자동 재연결 기능 추가, 인터뷰 세션 관련 API 반환 타입 수정
1 parent 5f905e1 commit abd85d8

File tree

7 files changed

+390
-106
lines changed

7 files changed

+390
-106
lines changed

apis/ai-interview-socket.ts

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export type AIInterviewSocketHandler = (data: AIInterviewSocketMessage) => void
88
export class AIInterviewSocket {
99
private socket: WebSocket | null = null
1010
private handler: AIInterviewSocketHandler | null = null
11+
private isConnecting = false
12+
private reconnectAttempts = 0
13+
private maxReconnectAttempts = 3
1114

1215
connect(
1316
interviewId: string,
@@ -16,15 +19,40 @@ export class AIInterviewSocket {
1619
fIndex: number,
1720
sessionId: string
1821
) {
19-
if (this.socket) return
22+
if (this.socket || this.isConnecting) {
23+
console.log('WebSocket 이미 연결 중이거나 연결됨')
24+
return
25+
}
26+
2027
const baseUrl = process.env.NEXT_PUBLIC_AI_WEBSOCKET_URL
21-
if (!baseUrl)
28+
if (!baseUrl) {
29+
console.error('NEXT_PUBLIC_AI_WEBSOCKET_URL 환경변수가 설정되지 않음')
2230
throw new Error('NEXT_PUBLIC_AI_WEBSOCKET_URL 환경변수가 필요합니다.')
31+
}
32+
33+
console.log(`WebSocket 연결 시도:`, {
34+
baseUrl,
35+
sessionId,
36+
interviewId,
37+
memberInterviewId,
38+
qIndex,
39+
fIndex,
40+
})
41+
this.isConnecting = true
2342

24-
console.log(`sessionId : ${sessionId}`)
2543
const url = `${baseUrl}?interview_id=${interviewId}&member_interview_id=${memberInterviewId}&session_id=${sessionId}&index=${qIndex}&f_index=${fIndex}`
44+
console.log('WebSocket URL:', url)
45+
2646
this.socket = new WebSocket(url)
47+
48+
this.socket.onopen = () => {
49+
console.log('✅ WebSocket 연결 성공')
50+
this.isConnecting = false
51+
this.reconnectAttempts = 0
52+
}
53+
2754
this.socket.onmessage = event => {
55+
console.log('📨 WebSocket 메시지 수신:', event.data)
2856
if (this.handler) {
2957
try {
3058
const data = JSON.parse(event.data)
@@ -34,21 +62,107 @@ export class AIInterviewSocket {
3462
}
3563
}
3664
}
37-
this.socket.onclose = () => {
65+
66+
this.socket.onclose = event => {
67+
console.log(`❌ WebSocket 연결 종료:`, {
68+
code: event.code,
69+
reason: event.reason,
70+
wasClean: event.wasClean,
71+
})
72+
73+
// 서버 오류 코드별 처리
74+
switch (event.code) {
75+
case 1000: // 정상 종료
76+
console.log('✅ WebSocket 정상 종료')
77+
break
78+
case 1011: // 서버 내부 오류
79+
console.error('💥 서버 내부 오류로 연결이 끊어졌습니다')
80+
break
81+
case 1006: // 비정상 종료
82+
console.error('💥 WebSocket 비정상 종료')
83+
break
84+
default:
85+
console.error(`💥 WebSocket 오류 코드: ${event.code}`)
86+
}
87+
3888
this.socket = null
89+
this.isConnecting = false
90+
91+
// 정상적인 종료가 아닌 경우 재연결 시도
92+
if (
93+
event.code !== 1000 &&
94+
this.reconnectAttempts < this.maxReconnectAttempts
95+
) {
96+
this.reconnectAttempts++
97+
const delay = Math.min(
98+
1000 * Math.pow(2, this.reconnectAttempts - 1),
99+
10000
100+
)
101+
console.log(
102+
`🔄 WebSocket 재연결 시도 ${this.reconnectAttempts}/${this.maxReconnectAttempts} (${delay}ms 후)`
103+
)
104+
setTimeout(() => {
105+
this.connect(
106+
interviewId,
107+
memberInterviewId,
108+
qIndex,
109+
fIndex,
110+
sessionId
111+
)
112+
}, delay)
113+
} else if (this.reconnectAttempts >= this.maxReconnectAttempts) {
114+
console.error('❌ WebSocket 최대 재연결 시도 횟수 초과')
115+
}
116+
}
117+
118+
this.socket.onerror = error => {
119+
console.error('💥 WebSocket 연결 에러:', error)
120+
console.error('💥 WebSocket URL:', this.socket?.url)
121+
console.error('💥 WebSocket ReadyState:', this.socket?.readyState)
122+
this.isConnecting = false
39123
}
40124
}
41125

42126
disconnect() {
43-
this.socket?.close()
44-
this.socket = null
127+
if (this.socket) {
128+
console.log('WebSocket 연결 해제')
129+
this.socket.close(1000, 'Client disconnect')
130+
this.socket = null
131+
}
132+
this.isConnecting = false
133+
this.reconnectAttempts = 0
45134
}
46135

47136
sendAudio(blob: Blob) {
48-
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return
49-
blob.arrayBuffer().then(buffer => {
50-
this.socket?.send(buffer)
51-
})
137+
if (!this.socket) {
138+
console.warn('❌ WebSocket 인스턴스가 없어 오디오 전송 실패')
139+
return
140+
}
141+
142+
if (this.socket.readyState !== WebSocket.OPEN) {
143+
console.warn('❌ WebSocket이 연결되지 않아 오디오 전송 실패', {
144+
readyState: this.socket.readyState,
145+
readyStateText: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][
146+
this.socket.readyState
147+
],
148+
})
149+
return
150+
}
151+
152+
console.log('🎤 오디오 전송 중...', { blobSize: blob.size })
153+
blob
154+
.arrayBuffer()
155+
.then(buffer => {
156+
try {
157+
this.socket?.send(buffer)
158+
console.log('✅ 오디오 전송 완료', { bufferSize: buffer.byteLength })
159+
} catch (error) {
160+
console.error('💥 오디오 전송 에러:', error)
161+
}
162+
})
163+
.catch(error => {
164+
console.error('💥 오디오 버퍼 변환 에러:', error)
165+
})
52166
}
53167

54168
onMessage(handler: AIInterviewSocketHandler) {

apis/ai-interview.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { aiFetch } from '../utils/fetch/fetch'
22
import type {
33
InterviewSession,
44
QA,
5-
InterviewStartResponseDTO,
5+
ApiResponseInterviewStartResponseDTO,
66
} from './types/interview-types'
77

88
export function toCamelCaseQA(raw: any): QA {
@@ -37,14 +37,14 @@ function toCamelCaseInterviewSession(raw: any): InterviewSession {
3737
export async function generateQuestions(
3838
interviewId: string,
3939
memberInterviewId: string,
40-
payload?: InterviewStartResponseDTO
40+
payload?: ApiResponseInterviewStartResponseDTO
4141
): Promise<InterviewSession> {
4242
const updatedPayload = {
4343
...payload,
4444
result: {
45-
...payload,
45+
...payload?.result,
4646
interview: {
47-
...payload?.interview,
47+
...payload?.result?.interview,
4848
notice_url:
4949
'https://hanabank.incruit.com/hire/viewhire.asp?projectid=113',
5050
},

app/workspace/interviews/session/[id]/page.tsx

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function InterviewSessionContent({
100100
connectWsAsync,
101101
} = socketCtx
102102

103-
const { isAnswering, handleStartAnswering, stopAnswering } =
103+
const { isAnswering, handleStartAnswering, stopAnswering, isWsConnected } =
104104
useInterviewRealtime()
105105

106106
useEffect(() => {
@@ -119,6 +119,20 @@ function InterviewSessionContent({
119119
const [isSubmitting, setIsSubmitting] = useState(false)
120120
const [answerText, setAnswerText] = useState('')
121121

122+
// WebSocket 연결 상태 모니터링
123+
useEffect(() => {
124+
if (isAnswering && !isWsConnected) {
125+
console.log('WebSocket 연결 끊어짐 감지, 답변 중단')
126+
stopAnswering()
127+
// 서버 오류인 경우 더 구체적인 메시지
128+
setTimeout(() => {
129+
alert(
130+
'음성 연결이 끊어졌습니다. 서버 문제일 수 있으니 잠시 후 다시 시도해주세요.'
131+
)
132+
}, 1000) // 1초 후 알림 (재연결 시도 시간 고려)
133+
}
134+
}, [isAnswering, isWsConnected, stopAnswering])
135+
122136
useEffect(() => {
123137
if (isAnswering && timer === 0 && !isSubmitting) {
124138
handleSubmit()
@@ -325,10 +339,6 @@ function InterviewSessionContent({
325339
<WebcamView />
326340
</div>
327341
</div>
328-
{/*
329-
<Button size="sm" className="ml-2 bg-[#8FD694] text-white" onClick={handlePlayAudio}>
330-
질문 읽어주기
331-
</Button> */}
332342
</div>
333343

334344
<div className="w-full md:w-80 bg-gray-800 p-4 flex flex-col">
@@ -394,25 +404,66 @@ function InterviewSessionContent({
394404
{/* Action Buttons & 녹음/녹화 상태 표시 */}
395405
<div className="mt-auto">
396406
{!isAnswering ? (
397-
<Button
398-
className="w-full bg-[#8FD694] hover:bg-[#7ac47f] text-white"
399-
disabled={isFeedbackLoading || isQuestionLoading}
400-
onClick={async () => {
401-
if (
402-
socketCtx.wsRef?.current &&
403-
socketCtx.wsRef.current.isConnected()
404-
) {
405-
handleStartAnswering()
406-
} else {
407-
await connectWsAsync()
408-
handleStartAnswering()
407+
<>
408+
{/* WebSocket 연결 상태 표시 */}
409+
{!isWsConnected && (
410+
<div className="mb-2 text-center text-sm text-yellow-400">
411+
⚠️ 음성 연결이 끊어졌습니다. 다시 연결 중...
412+
<br />
413+
<span className="text-xs text-gray-400">
414+
음성 연결이 안 되면 텍스트로 답변하실 수 있습니다.
415+
</span>
416+
</div>
417+
)}
418+
<Button
419+
className={`w-full text-white ${
420+
isWsConnected
421+
? 'bg-[#8FD694] hover:bg-[#7ac47f]'
422+
: 'bg-gray-500 cursor-not-allowed'
423+
}`}
424+
disabled={
425+
isFeedbackLoading || isQuestionLoading || !isWsConnected
409426
}
410-
}}
411-
>
412-
답변 시작
413-
</Button>
427+
onClick={async () => {
428+
if (
429+
socketCtx.wsRef?.current &&
430+
socketCtx.wsRef.current.isConnected()
431+
) {
432+
handleStartAnswering()
433+
} else {
434+
try {
435+
await connectWsAsync()
436+
handleStartAnswering()
437+
} catch (error) {
438+
console.error('WebSocket 연결 실패:', error)
439+
// 서버 오류인 경우 더 구체적인 메시지
440+
if (
441+
error instanceof Error &&
442+
error.message.includes('시간 초과')
443+
) {
444+
alert(
445+
'서버 연결에 문제가 있습니다. 잠시 후 다시 시도하거나 페이지를 새로고침해주세요.'
446+
)
447+
} else {
448+
alert(
449+
'음성 연결에 실패했습니다. 페이지를 새로고침해주세요.'
450+
)
451+
}
452+
}
453+
}
454+
}}
455+
>
456+
{isWsConnected ? '답변 시작' : '연결 중...'}
457+
</Button>
458+
</>
414459
) : (
415460
<>
461+
{/* WebSocket 연결 상태 표시 */}
462+
{!isWsConnected && (
463+
<div className="mb-2 text-center text-sm text-red-400">
464+
❌ 음성 연결이 끊어졌습니다
465+
</div>
466+
)}
416467
<div className="mb-2 text-center text-sm text-[#8FD694]">
417468
음성 인식 결과 : {voiceAnswerText}
418469
</div>

app/workspace/interviews/session/hooks/interview/useGenerateOrGetSession.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useMutation } from '@tanstack/react-query'
22
import { generateQuestions, getSessionById } from '@/apis/ai-interview'
3-
import { preloadAllAudios } from '@/utils/audio'
43
import { InterviewState } from '@/types/interview/interview'
54
import { toast } from 'sonner'
65
import { useRouter } from 'next/navigation'
6+
import { ApiResponseInterviewStartResponseDTO } from '@/apis/types/interview-types'
77

88
interface UseGenerateOrGetSessionProps {
99
interviewId: string
@@ -25,7 +25,7 @@ export function useGenerateOrGetSession({
2525
interviewDetail,
2626
}: {
2727
memberInterviewId: number
28-
interviewDetail: any
28+
interviewDetail: ApiResponseInterviewStartResponseDTO
2929
}) => {
3030
return await generateQuestions(
3131
interviewId,
@@ -55,7 +55,7 @@ export function useGenerateOrGetSession({
5555

5656
const handleGenerateOrGetSession = async (
5757
memberInterviewId: number,
58-
interviewDetail: any
58+
interviewDetail: ApiResponseInterviewStartResponseDTO
5959
) => {
6060
// 기존 세션이 있는지 확인
6161
if (interviewState.session?.sessionId) {

0 commit comments

Comments
 (0)