테마 전환

단계별 튜토리얼: Gemini Multimodal Live API로 저지연 음성·영상 AI 어시스턴트 구축하기

Easton editorial illustration: one live assistant core connected to audio and video stream cards

Gemini Live API는 오디오 입력과 출력을 기본 지원하며, 엔드투엔드 아키텍처를 통해 ASR/TTS 중계 없이 지연 시간을 500ms 이내로 낮춥니다. 이 글에서는 이 API를 사용해 실제로 실시간 대화가 가능한 AI 어시스턴트를 만드는 방법을 소개합니다.

Gemini Multimodal Live API란?

먼저 개념부터 명확히 해보겠습니다. 기존 Gemini API는 어떻게 작동할까요? 텍스트를 보내면 텍스트로 답합니다. 단순하고 직관적입니다. 하지만 음성 상호작용을 구현하려면 ASR(음성 인식)과 TTS(음성 합성)를 직접 연결해야 하고, 여러 단계를 거치면서 지연 시간이 크게 늘어납니다.

Gemini Multimodal Live API의 차별점은 오디오 입력과 출력을 기본 지원한다는 것입니다. 즉, 마이크로 수집한 소리를 그대로 전송할 수 있고 응답도 순수 오디오 스트림으로 받기 때문에 중간에 별도의 형식 변환을 할 필요가 없습니다. 이러한 엔드투엔드 아키텍처는 지연 시간을 500ms 이내로 직접 낮춰 줍니다.

스마트 홈 프로젝트를 만들면서 이 기능을 사용해 본 적이 있습니다. 사용자가 “거실 조명을 조금 어둡게 해줘”라고 말하자 거의 말이 끝나는 순간 AI가 응답했습니다. 너무 자연스러워서 상대가 프로그램이라는 사실을 잊을 정도였습니다.

현재 지원되는 모델은 gemini-2.0-flash-native-audio-preview입니다. 이 버전 번호를 기억해 두세요. Google이 빠르게 개선하고 있으므로 업데이트를 정기적으로 확인하는 것이 좋습니다.

아키텍처 설계와 기술 선택

이제 시스템을 어떻게 구성할지 살펴보겠습니다. 제가 권하는 방식은 프런트엔드와 백엔드를 분리하는 아키텍처입니다. 이유는 간단합니다. API Key를 프런트엔드에 노출해서는 안 되기 때문입니다.

전체 데이터 흐름은 다음과 같습니다.

[브라우저] --WebSocket--> [Python 백엔드 프록시] --WebSocket--> [Gemini Live API]
    |                             |                              |
마이크 수집                  중계+비즈니스 로직                  AI 처리
스피커 재생                  VAD 감지/끼어들기 제어              오디오 생성

브라우저를 Gemini에 직접 연결하면 안 되는지 궁금할 수 있습니다. 기술적으로는 가능하지만, 그렇게 하려면 API Key를 JavaScript에 넣어야 합니다. 누구든 개발자 도구를 열어 키를 가져갈 수 있다는 뜻입니다. 저도 한 번 그런 실수를 했다가 다음 날 요금이 폭증했습니다. 뼈아픈 경험이었습니다.

따라서 기술 스택은 다음과 같이 정했습니다.

계층기술용도
프런트엔드순수 JavaScript + Web Audio API오디오 수집 및 재생, AudioWorklet 실시간 처리
백엔드Python 3.9+ + websockets 라이브러리WebSocket 프록시, VAD 감지, 세션 관리
프로토콜WebSocket + JSONGemini와의 양방향 통신

Web Audio API의 AudioWorklet은 꽤 유용합니다. 별도 스레드에서 오디오를 처리하므로 메인 스레드를 차단하지 않습니다. 뒤에서 구체적인 구현 코드를 살펴보겠습니다.

WebSocket 연결 설정과 세션 관리

이제 코드를 작성해 보겠습니다. 먼저 Gemini 서비스에 연결하는 방법부터 해결해야 합니다.

Live API의 WebSocket 엔드포인트는 다음과 같습니다.

wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=YOUR_API_KEY

여기서 v1alpha에 주목하세요. 아직 프리뷰 버전이므로 인터페이스가 바뀔 수 있습니다. 프로덕션 환경에서 사용할 때는 이 점을 염두에 두어야 합니다.

연결이 설정되면 가장 먼저 Setup 메시지를 보내 Gemini에 원하는 대화 방식을 알려 줍니다.

import asyncio
import json
import websockets

GEMINI_API_KEY = "your-api-key-here"
GEMINI_WS_URL = (
    f"wss://generativelanguage.googleapis.com/ws/"
    f"google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent"
    f"?key={GEMINI_API_KEY}"
)

CONFIG = {
    "setup": {
        "model": "models/gemini-2.0-flash-native-audio-preview",
        "generation_config": {
            "response_modalities": ["AUDIO"],
            "speech_config": {
                "voice_config": {
                    "prebuilt_voice_config": {
                        "voice_name": "Charon"  # 선택 항목: Charon, Aoede 등
                    }
                }
            }
        },
        "system_instruction": {
            "parts": [{"text": "당신은 helpful한 AI 어시스턴트입니다. 간결하고 자연스럽게 답하세요."}]
        }
    }
}

async def connect():
    async with websockets.connect(GEMINI_WS_URL) as ws:
        # setup 설정 전송
        await ws.send(json.dumps(CONFIG))

        # setup complete 응답 대기
        response = await ws.recv()
        data = json.loads(response)

        if "setupComplete" in data:
            print("✅ 연결에 성공했습니다. 이제 대화를 시작할 수 있습니다")
            return ws
        else:
            raise Exception(f"Setup 실패: {data}")

여기에는 몇 가지 살펴볼 만한 매개변수가 있습니다.

  • response_modalities: ['AUDIO']로 설정하면 음성 응답만 받습니다. 텍스트도 원한다면 ['AUDIO', 'TEXT']로 변경할 수 있습니다.
  • voice_name: Gemini는 몇 가지 사전 설정 음성을 제공합니다. 저는 차분하게 들리는 Charon을 선호합니다.

연결이 끊어진 뒤 재연결할 때는 지수 백오프 전략을 권합니다. 처음부터 무작정 재시도하면 서비스에 과도한 부하를 줄 수 있습니다.

async def connect_with_retry(max_retries=5):
    for attempt in range(max_retries):
        try:
            return await connect()
        except Exception as e:
            wait_time = min(2 ** attempt, 30)  # 최대 30초 대기
            print(f"연결 실패 ({e}), {wait_time}초 후 재시도합니다...")
            await asyncio.sleep(wait_time)
    raise Exception("여러 번 재시도했지만 연결하지 못했습니다")

16kHz PCM 오디오 스트림 수집과 전송

연결을 준비했으니 이제 오디오를 어디서 가져와 어떻게 보낼지 해결할 차례입니다.

먼저 16kHz를 선택하는 이유를 설명하겠습니다. 사람 목소리의 주파수 범위는 일반적으로 85Hz에서 255Hz 사이입니다. 남성 목소리는 낮고 여성 목소리는 높은 편입니다. 나이퀴스트 샘플링 정리에 따르면 이론적으로는 8kHz면 충분합니다. 하지만 실제로 세부 정보를 어느 정도 보존하려면 16kHz가 음질과 데이터 크기의 균형을 맞추는 sweet spot입니다. Gemini도 이 샘플링 레이트를 권장합니다.

프런트엔드 수집 코드는 다음과 같습니다.

class AudioRecorder {
  constructor() {
    this.sampleRate = 16000;
    this.bufferSize = 1024;
    this.audioContext = null;
    this.workletNode = null;
    this.stream = null;
    this.onAudioData = null; // 콜백 함수
  }

  async start() {
    // 마이크 권한 요청
    this.stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        sampleRate: 16000,
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true
      }
    });

    // AudioContext를 생성하고 샘플링 레이트 강제 지정
    this.audioContext = new AudioContext({
      sampleRate: 16000
    });

    // AudioWorklet 프로세서 로드
    await this.audioContext.audioWorklet.addModule('pcm-processor.js');

    const source = this.audioContext.createMediaStreamSource(this.stream);
    this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor');

    // 오디오 데이터 처리
    this.workletNode.port.onmessage = (event) => {
      const float32Data = event.data;

      // Int16 PCM으로 변환
      const int16Data = this.float32ToInt16(float32Data);

      // Base64로 인코딩한 뒤 전송
      const base64Data = btoa(String.fromCharCode(...new Uint8Array(int16Data.buffer)));

      if (this.onAudioData) {
        this.onAudioData(base64Data);
      }
    };

    source.connect(this.workletNode);
    console.log('🎤 오디오 수집을 시작했습니다');
  }

  float32ToInt16(float32Array) {
    const int16Array = new Int16Array(float32Array.length);
    for (let i = 0; i < float32Array.length; i++) {
      // Float32 (-1.0 ~ 1.0) -> Int16 (-32768 ~ 32767)
      const s = Math.max(-1, Math.min(1, float32Array[i]));
      int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
    }
    return int16Array;
  }

  stop() {
    if (this.workletNode) {
      this.workletNode.disconnect();
    }
    if (this.audioContext) {
      this.audioContext.close();
    }
    if (this.stream) {
      this.stream.getTracks().forEach(track => track.stop());
    }
    console.log('🛑 오디오 수집을 중지했습니다');
  }
}

AudioWorklet에는 별도의 pcm-processor.js 파일이 필요합니다.

// pcm-processor.js
class PCMProcessor extends AudioWorkletProcessor {
  process(inputs, outputs, parameters) {
    const input = inputs[0];
    if (input && input[0]) {
      // 메인 스레드로 전송
      this.port.postMessage(input[0].slice());
    }
    return true; // 프로세서를 활성 상태로 유지
  }
}

registerProcessor('pcm-processor', PCMProcessor);

백엔드가 데이터를 받으면 Gemini로 전달해야 합니다.

async def send_audio(ws, base64_pcm_data):
    """오디오 데이터를 Gemini로 전송합니다"""
    message = {
        "realtime_input": {
            "media_chunks": [{
                "mime_type": "audio/pcm;rate=16000",
                "data": base64_pcm_data
            }]
        }
    }
    await ws.send(json.dumps(message))

여기서 주의할 점이 있습니다. 일부 브라우저의 getUserMedia는 지정한 sampleRate를 무시하고 실제로 44.1kHz나 48kHz를 반환할 수 있습니다. 안전하게 처리하려면 AudioContext에서 다시 리샘플링하거나 audiobuffer-to-wav 같은 서드파티 라이브러리를 사용하는 것이 좋습니다.

VAD 음성 활동 감지 구현

이제 또 다른 문제가 생깁니다. 아무 조건 없이 모든 오디오를 Gemini에 전송하면 아무도 말하지 않는 동안에도 데이터가 전달되어 대역폭과 비용이 모두 낭비됩니다. 이때 필요한 것이 VAD(Voice Activity Detection, 음성 활동 감지)입니다.

VAD의 역할은 단순합니다. 해당 오디오 구간에 사람이 말하고 있는지 판단합니다. 말할 때만 보내고, 아무도 말하지 않을 때는 전송을 멈춥니다.

Google이 오픈 소스로 공개한 WebRTC VAD를 권합니다. 가볍고 빠르며 성능도 좋습니다. Python에는 webrtcvad라는 래퍼 라이브러리가 있습니다.

import webrtcvad
import collections
import numpy as np

class VADProcessor:
    def __init__(self, aggressiveness=2, frame_duration_ms=20):
        """
        aggressiveness: 0~3, 높을수록 엄격함(음성을 무음으로 판단하기 쉬움)
        frame_duration_ms: 10, 20, or 30
        """
        self.vad = webrtcvad.Vad(aggressiveness)
        self.frame_duration_ms = frame_duration_ms
        self.sample_rate = 16000

        # 평활화를 위한 링 버퍼
        self.ring_buffer = collections.deque(maxlen=30)  # 600ms
        self.triggered = False

    def process_frame(self, pcm_bytes):
        """
        오디오 프레임 하나를 처리하고 전송 여부를 반환합니다
        """
        is_speech = self.vad.is_speech(pcm_bytes, self.sample_rate)

        if not self.triggered:
            # 비활성 상태: 음성 프레임 누적
            self.ring_buffer.append((pcm_bytes, is_speech))
            num_voiced = sum(1 for _, speech in self.ring_buffer if speech)

            # 프레임의 90%가 음성이면 활성화
            if num_voiced > 0.9 * self.ring_buffer.maxlen:
                self.triggered = True
                # 버퍼의 데이터도 함께 전송
                return b''.join([f for f, _ in self.ring_buffer])
            return None
        else:
            # 활성 상태
            if is_speech:
                self.ring_buffer.append((pcm_bytes, True))
                return pcm_bytes
            else:
                self.ring_buffer.append((pcm_bytes, False))
                num_unvoiced = sum(1 for _, speech in self.ring_buffer if not speech)

                # 90%가 무음이면 비활성화
                if num_unvoiced > 0.9 * self.ring_buffer.maxlen:
                    self.triggered = False
                    self.ring_buffer.clear()
                return pcm_bytes

사용 방법은 대략 다음과 같습니다.

vad = VADProcessor(aggressiveness=2)

async def handle_client_audio(websocket, gemini_ws):
    async for message in websocket:
        data = json.loads(message)

        if 'audio' in data:
            pcm_bytes = base64.b64decode(data['audio'])

            # VAD 감지
            result = vad.process_frame(pcm_bytes)

            if result:
                # 음성이 있으면 Gemini로 전달
                await send_audio(gemini_ws, base64.b64encode(result).decode())

aggressiveness 매개변수는 조정이 다소 까다롭습니다. 너무 낮으면 작은 배경 소음까지 음성으로 판단하고, 너무 높으면 작은 목소리를 놓칠 수 있습니다. 제 경험으로는 2부터 시작해 실제 환경에 맞게 미세 조정하는 것이 좋습니다.

배포 환경에 webrtcvad를 설치할 수 없다면 간단한 에너지 임계값 감지를 대안으로 사용할 수도 있습니다.

// 프런트엔드 대안: RMS 에너지 기반의 간단한 감지
function detectVoiceActivity(audioData, threshold = 0.015) {
    const sum = audioData.reduce((acc, val) => acc + val * val, 0);
    const rms = Math.sqrt(sum / audioData.length);
    return rms > threshold;
}

자연스러운 끼어들기(Barge-in) 기능 구현

일부 음성 어시스턴트와 대화할 때 답답함을 느낀 적이 있을 겁니다. 일단 긴 설명을 시작하면 끝날 때까지 기다려야 하고 중간에 끼어들 수도 없습니다.

Barge-in, 즉 끼어들기 기능은 바로 이 문제를 해결합니다. AI가 말하는 중에도 사용자가 바로 말을 시작해 중단시킬 수 있으며, AI는 현재 출력을 즉시 멈추고 사용자의 말을 듣습니다.

좋은 소식은 Gemini Live API가 이 기능을 기본 지원하며 구현도 꽤 지능적이라는 점입니다. 설정에서 자동 활동 감지만 활성화하면 됩니다.

CONFIG = {
    "setup": {
        "model": "models/gemini-2.0-flash-native-audio-preview",
        "generation_config": {
            "response_modalities": ["AUDIO"],
        },
        "realtime_input_config": {
            "automatic_activity_detection": {
                "disabled": False,
                "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH",
                "end_of_speech_sensitivity": "END_SENSITIVITY_LOW"
            }
        }
    }
}

sensitivity 설정에는 약간의 요령이 필요합니다.

  • start_of_speech_sensitivityHIGH로 설정하면 AI가 사용자의 발화 시작에 더 민감하게 반응해 끼어들기가 쉽게 활성화됩니다.
  • end_of_speech_sensitivityLOW로 설정하면 사용자가 실제로 말을 마쳤는지 AI가 조금 더 기다린 뒤 응답하므로 오판을 줄일 수 있습니다.

클라이언트에서는 interrupted 이벤트를 수신한 뒤 즉시 재생을 중지하면 됩니다.

class GeminiClient {
  constructor() {
    this.audioQueue = [];
    this.isPlaying = false;
    this.currentSource = null;
  }

  async handleMessage(event) {
    const message = JSON.parse(event.data);

    // 중단 신호 처리
    if (message.server_content?.interrupted) {
      console.log('⚡ 사용자가 끼어들었습니다. 재생을 중지합니다');
      this.stopPlayback();
      return;
    }

    // AI가 반환한 오디오 처리
    if (message.server_content?.model_turn) {
      const parts = message.server_content.model_turn.parts;

      for (const part of parts) {
        if (part.inline_data?.mime_type.startsWith('audio/')) {
          const audioData = base64ToArrayBuffer(part.inline_data.data);
          this.queueAudio(audioData);
        }
      }
    }
  }

  stopPlayback() {
    // 재생 큐 비우기
    this.audioQueue = [];
    this.isPlaying = false;

    // 현재 재생 중인 오디오 중지
    if (this.currentSource) {
      try {
        this.currentSource.stop();
      } catch (e) {
        // 이미 중지되었을 수 있음
      }
      this.currentSource = null;
    }
  }

  async queueAudio(audioData) {
    this.audioQueue.push(audioData);
    if (!this.isPlaying) {
      this.playNext();
    }
  }

  async playNext() {
    if (this.audioQueue.length === 0) {
      this.isPlaying = false;
      return;
    }

    this.isPlaying = true;
    const audioData = this.audioQueue.shift();

    // 디코딩 후 재생
    const audioBuffer = await this.audioContext.decodeAudioData(audioData.slice());
    this.currentSource = this.audioContext.createBufferSource();
    this.currentSource.buffer = audioBuffer;
    this.currentSource.connect(this.audioContext.destination);

    this.currentSource.onended = () => {
      this.playNext();
    };

    this.currentSource.start();
  }
}

여기서 주의할 점이 하나 있습니다. 오디오가 이미 자연스럽게 재생을 마쳤다면 stop() 메서드가 예외를 던질 수 있습니다. 그래서 콘솔이 오류로 가득 차지 않도록 try-catch를 추가했습니다.

성능 최적화와 지연 시간 제어

마지막으로 시스템 지연 시간을 최소화하는 방법을 살펴보겠습니다.

먼저 지연 시간이 어디서 발생하는지 알아야 합니다.

  1. 네트워크 전송: 데이터 패킷이 브라우저에서 서버를 거쳐 Gemini까지 왕복하는 시간
  2. 오디오 인코딩과 디코딩: PCM 압축 및 압축 해제 시간(다만 PCM 자체가 무손실이라 이 부분의 오버헤드는 매우 작습니다)
  3. 버퍼 누적: 원활한 재생을 위해 설정한 버퍼 깊이

이 요소들을 고려해 제가 사용하는 최적화 방법은 다음과 같습니다.

버퍼 깊이 줄이기

재생 버퍼는 너무 크게 설정하지 말고 필요한 만큼만 사용하세요. 저는 보통 100~200ms를 사용합니다.

// 작은 버퍼 설정
const audioContext = new AudioContext({
  sampleRate: 16000,
  latencyHint: 'interactive'  // 저지연 모드
});

적응형 비트 전송률(실제로는 주로 적응형 버퍼)

네트워크 변동이 크다고 감지되면 버퍼를 조금 늘리고, 네트워크가 안정적일 때는 다시 줄일 수 있습니다.

로컬 에코 제거

사용자가 헤드폰 대신 스피커를 켜 두면 AI의 목소리가 마이크로 다시 들어가 순환이 발생합니다. 다행히 getUserMedia에는 에코 제거 기능이 포함되어 있습니다.

navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
})

지표 모니터링

최적화가 효과가 있는지 어떻게 알 수 있을까요? Performance API로 측정 지점을 기록할 수 있습니다.

// 지연 시간 지표 기록
class LatencyMonitor {
  constructor() {
    this.metrics = [];
  }

  recordSendTime() {
    this.lastSendTime = performance.now();
  }

  recordReceiveTime() {
    const latency = performance.now() - this.lastSendTime;
    this.metrics.push(latency);

    // 최근 100개 기록 유지
    if (this.metrics.length > 100) {
      this.metrics.shift();
    }

    // 평균 지연 시간 계산
    const avg = this.metrics.reduce((a, b) => a + b, 0) / this.metrics.length;
    console.log(`📊 평균 지연 시간: ${avg.toFixed(2)}ms`);
  }
}

테스트 환경에서 측정한 결과는 대략 다음과 같았습니다.

  • 엔드투엔드 지연 시간: 300~500ms(네트워크 상태에 따라 달라짐)
  • 첫 패킷 응답 시간: 200~400ms
  • 연속 대화 지연 시간: 150~300ms

지연 시간이 이보다 눈에 띄게 길다면 다음 체크리스트를 따라 확인해 보세요.

  • WebSocket 연결이 HTTPS/WSS를 사용하고 있나요? HTTP에는 추가 오버헤드가 발생합니다.
  • 서버가 어디에 배포되어 있나요? Google 데이터 센터와 가까울수록 좋습니다.
  • VAD 감지로 인해 지연 시간이 너무 많이 늘어나지는 않았나요? 프레임 길이를 줄여 보세요.
  • 프런트엔드 재생 버퍼를 너무 크게 설정하지 않았나요?

오디오 컨텍스트와 관련된 또 다른 주의점도 있습니다. Chrome은 사용자가 상호작용한 뒤에만 소리 재생을 허용합니다. 따라서 페이지가 열리자마자 자동으로 재생하지 말고 ‘대화 시작’ 버튼을 추가하세요.

마무리

여기까지 Gemini Live API 애플리케이션을 개발하는 전체 과정을 살펴봤습니다. 기본 개념과 아키텍처 설계부터 WebSocket 연결, 오디오 수집, VAD 감지, 끼어들기 기능, 마지막 성능 최적화까지 제가 직접 겪은 시행착오를 단계마다 최대한 공유했습니다.

솔직히 실시간 음성 상호작용 분야는 여전히 빠르게 발전하고 있으며 Gemini Live API 자체도 계속 업데이트되고 있습니다. 하지만 이 기본 아키텍처는 충분히 검증을 견딜 수 있다고 생각합니다. 적어도 제 프로젝트에서는 몇 달 동안 안정적으로 작동했습니다.

실제 개발 과정에서 문제가 생기면 언제든 함께 이야기해 보세요. 기술은 혼자 탐색하는 것보다 여러 사람이 함께 논의할 때 훨씬 빠르게 발전할 수 있으니까요.

FAQ

왜 프런트엔드와 백엔드를 반드시 분리해야 하나요?
API Key는 백엔드에 보관해야 하며 프런트엔드 JavaScript에 노출해서는 안 됩니다. 브라우저가 Gemini에 직접 연결하면 누구나 개발자 도구에서 키를 확인할 수 있어 악용이나 막대한 요금이 발생할 수 있습니다. 프런트엔드는 WebSocket으로 Python 백엔드 프록시에 연결하고, 백엔드가 요청을 Gemini Live API로 전달합니다.
왜 16kHz 샘플링 레이트를 선택하나요?
사람 목소리의 주파수 범위는 약 85~255Hz이며, 나이퀴스트 샘플링 정리에 따르면 이론적으로 8kHz면 충분합니다. 하지만 16kHz는 더 많은 세부 정보를 보존하면서 음질과 데이터 크기의 균형을 맞추는 sweet spot입니다. Gemini도 16kHz를 권장하므로 음성 인식 정확도를 유지하면서 대역폭 비용을 관리할 수 있습니다.
VAD의 aggressiveness 매개변수는 어떻게 조정하나요?
aggressiveness 범위는 0~3이며 값이 높을수록 판정이 엄격해져 음성을 무음으로 분류하기 쉬워집니다. 먼저 2로 테스트하는 것이 좋습니다. 너무 낮으면 배경 소음을 음성으로 잘못 판단해 대역폭 사용량이 늘고, 너무 높으면 작은 목소리를 놓칠 수 있습니다. 실제 환경의 소음 수준에 맞춰 미세 조정하세요.
Barge-in 기능은 별도로 개발해야 하나요?
Gemini Live API는 Barge-in을 기본 지원하므로 설정에서 automatic_activity_detection만 활성화하면 됩니다. 클라이언트는 interrupted 이벤트를 수신하고 즉시 오디오 재생을 중지해야 합니다. 큐를 비우고 현재 재생을 멈추는 등 오디오 플레이어의 중지 로직을 제대로 처리하는 것이 핵심입니다.

5분 읽기 · 게시일: 2026년 2월 27일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog