이미지 로딩 중...

FastAPI 기반 음성 비서 서버 배포 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 19. · 2 Views

FastAPI 기반 음성 비서 서버 배포 완벽 가이드

음성 인식과 TTS를 결합한 실시간 음성 비서 서버를 FastAPI로 구축하고 Docker로 배포하는 전체 과정을 다룹니다. 프로젝트 설계부터 HTTPS 보안 설정까지, 실무에서 바로 적용 가능한 상세한 가이드를 제공합니다.


목차

  1. FastAPI 프로젝트 구조 설계
  2. /tts 엔드포인트 구현 (Text → Audio)
  3. /chat 엔드포인트 구현 (대화형)
  4. WebSocket 연결로 실시간 통신
  5. Docker 컨테이너화 및 배포
  6. HTTPS 설정 및 보안 고려사항

1. FastAPI 프로젝트 구조 설계

시작하며

여러분이 음성 비서 서버를 만들려고 할 때 가장 먼저 막막한 부분이 뭔가요? 바로 "어디서부터 시작해야 하지?"라는 질문입니다.

파일을 어떻게 나눠야 할지, 어떤 폴더에 무엇을 넣어야 할지, 설정은 어디에 관리해야 할지 고민되죠. 실제로 많은 초보 개발자들이 모든 코드를 하나의 main.py 파일에 넣고 시작합니다.

처음엔 괜찮아 보이지만, 기능이 추가될수록 코드가 엉망이 되고, 나중에는 어디를 고쳐야 할지조차 찾기 어려워집니다. 팀 프로젝트라면 더 큰 문제가 되죠.

바로 이럴 때 필요한 것이 체계적인 프로젝트 구조 설계입니다. 마치 집을 지을 때 설계도가 필요하듯이, 서버 개발도 탄탄한 구조 위에서 시작해야 나중에 유지보수가 쉽고 확장도 간편합니다.

개요

간단히 말해서, 프로젝트 구조 설계란 여러분의 코드를 논리적으로 분류하고 정리하는 청사진입니다. 집을 지을 때 거실, 주방, 침실을 구분하듯이, 서버 코드도 역할에 따라 명확히 나누는 것이죠.

FastAPI 프로젝트에서 이런 구조가 왜 중요할까요? 음성 비서 서버는 TTS(Text-to-Speech), 음성 인식, LLM 연동, WebSocket 통신 등 다양한 기능이 복잡하게 얽혀 있습니다.

예를 들어, 음성 파일 처리 로직과 데이터베이스 연결 코드가 섞여 있다면, 나중에 TTS 엔진만 바꾸려고 해도 전체 코드를 뒤져야 하는 악몽을 겪게 됩니다. 기존에는 Django처럼 무겁고 정해진 구조를 따라야 했다면, FastAPI는 자유도가 높아 여러분이 직접 구조를 설계할 수 있습니다.

하지만 이 자유가 오히려 초보자에게는 혼란을 줄 수 있죠. FastAPI 프로젝트 구조의 핵심 특징은 다음과 같습니다: (1) 라우터를 분리하여 엔드포인트별로 관리, (2) 서비스 레이어로 비즈니스 로직 분리, (3) 설정과 의존성을 별도 모듈로 관리.

이러한 특징들이 코드의 재사용성을 높이고, 테스트를 쉽게 만들며, 여러 개발자가 동시에 작업할 수 있게 해줍니다.

코드 예제

# 프로젝트 루트 구조
voice_assistant/
├── app/
│   ├── __init__.py
│   ├── main.py              # FastAPI 앱 진입점
│   ├── config.py            # 환경 설정 관리
│   ├── routers/             # API 라우터들
│   │   ├── __init__.py
│   │   ├── tts.py          # TTS 엔드포인트
│   │   ├── chat.py         # 채팅 엔드포인트
│   │   └── websocket.py    # WebSocket 연결
│   ├── services/            # 비즈니스 로직
│   │   ├── __init__.py
│   │   ├── tts_service.py  # TTS 처리
│   │   └── llm_service.py  # LLM 연동
│   ├── models/              # 데이터 모델
│   │   ├── __init__.py
│   │   └── schemas.py      # Pydantic 스키마
│   └── utils/               # 유틸리티 함수
│       ├── __init__.py
│       └── audio.py        # 오디오 처리
├── tests/                   # 테스트 코드
├── Dockerfile
├── requirements.txt
└── .env                     # 환경 변수

설명

이것이 하는 일: 이 프로젝트 구조는 음성 비서 서버의 각 기능을 명확히 분리하여 개발, 테스트, 배포를 효율적으로 만듭니다. 첫 번째로, app/ 디렉토리가 모든 애플리케이션 코드를 담는 컨테이너 역할을 합니다.

main.py는 서버의 시작점으로, FastAPI 앱을 초기화하고 모든 라우터를 등록하죠. config.py에서는 데이터베이스 URL, API 키, TTS 모델 경로 같은 설정값들을 환경 변수에서 읽어와 중앙에서 관리합니다.

이렇게 하면 개발 환경과 프로덕션 환경의 설정을 쉽게 전환할 수 있습니다. 그 다음으로, routers/ 폴더가 실행되면서 각 API 엔드포인트를 담당합니다.

tts.py는 텍스트를 음성으로 변환하는 POST 요청을 처리하고, chat.py는 대화형 인터페이스를 제공하며, websocket.py는 실시간 양방향 통신을 담당합니다. 각 라우터는 요청을 받아 검증하고, services/ 폴더의 비즈니스 로직을 호출한 뒤, 적절한 응답을 반환합니다.

이렇게 분리하면 한 엔드포인트를 수정할 때 다른 엔드포인트에 영향을 주지 않습니다. 세 번째로, services/ 폴더가 실제 핵심 로직을 수행합니다.

tts_service.py는 Coqui TTS나 gTTS 같은 라이브러리를 사용해 텍스트를 오디오로 변환하고, llm_service.py는 OpenAI API나 로컬 LLM을 호출해 대화를 생성합니다. 이 레이어를 분리하면 나중에 TTS 엔진을 바꾸거나 LLM 모델을 업그레이드할 때 서비스 파일만 수정하면 되죠.

마지막으로, models/schemas.py에서 Pydantic 모델을 정의하여 요청과 응답 데이터의 형식을 명확히 합니다. 예를 들어, TTS 요청은 text, language, voice_id 같은 필드를 가져야 하고, 응답은 audio_url과 duration을 포함해야 한다고 정의합니다.

utils/ 폴더에는 오디오 파일 변환, 파일 저장 같은 공통 기능을 넣어 여러 곳에서 재사용합니다. 여러분이 이 구조를 사용하면 새로운 기능 추가가 매우 간단해집니다.

예를 들어 음성 인식 기능을 추가하려면 routers/에 stt.py를 만들고, services/에 stt_service.py를 추가하고, main.py에 라우터만 등록하면 끝입니다. 또한 각 부분을 독립적으로 테스트할 수 있어 버그를 빠르게 찾을 수 있고, 여러 개발자가 동시에 다른 라우터나 서비스를 작업할 수 있습니다.

실전 팁

💡 config.py에서 pydantic.BaseSettings를 사용하면 환경 변수를 타입 안전하게 관리할 수 있고, .env 파일도 자동으로 로드됩니다. API 키 같은 민감한 정보는 절대 코드에 직접 넣지 마세요.

💡 routers/ 폴더의 각 파일에서 APIRouter()를 생성하고 prefix를 지정하면 (예: prefix="/tts"), URL 구조가 명확해지고 나중에 버전 관리도 쉽습니다 (예: /api/v1/tts).

💡 services/ 레이어를 만들 때는 의존성 주입(Dependency Injection) 패턴을 사용하세요. FastAPI의 Depends()를 활용하면 테스트할 때 실제 TTS 엔진 대신 mock 객체를 주입할 수 있습니다.

💡 models/schemas.py에서 BaseModel을 상속할 때 Config 클래스에 schema_extra를 추가하면 Swagger UI에 예시 데이터가 표시되어 API 문서가 훨씬 이해하기 쉬워집니다.

💡 프로젝트가 커지면 services/ 안에 다시 폴더를 만들어 세분화하세요 (예: services/tts/coqui.py, services/tts/gtts.py). 이렇게 하면 여러 TTS 엔진을 지원하면서도 코드가 깔끔하게 유지됩니다.


2. /tts 엔드포인트 구현 (Text → Audio)

시작하며

여러분이 웹사이트나 앱에서 "이 텍스트를 읽어줘!"라고 하면 음성이 나오는 기능을 만들어본 적 있나요? 생각보다 복잡한 작업입니다.

텍스트를 받아서, TTS 엔진으로 변환하고, 오디오 파일을 저장한 뒤, 사용자에게 전달하는 전체 파이프라인을 구축해야 하죠. 많은 개발자들이 이 과정에서 실수하는 부분이 있습니다.

큰 텍스트를 한 번에 변환하려다 서버 메모리가 부족해지거나, 오디오 파일을 메모리에 계속 쌓아두어 디스크가 가득 차는 문제입니다. 또한 동시에 여러 요청이 들어오면 응답 시간이 급격히 느려지는 경우도 흔합니다.

바로 이럴 때 필요한 것이 제대로 설계된 /tts 엔드포인트입니다. 비동기 처리, 파일 관리, 에러 핸들링을 모두 고려한 견고한 API를 만들어야 실제 서비스에서 안정적으로 작동합니다.

개요

간단히 말해서, /tts 엔드포인트는 클라이언트가 보낸 텍스트를 받아 음성 파일로 변환하고, 다운로드 가능한 URL이나 오디오 데이터를 반환하는 API입니다. 마치 번역기처럼, 글자를 소리로 바꿔주는 역할이죠.

이 엔드포인트가 왜 필요할까요? 시각 장애인을 위한 접근성 기능, 학습 앱의 발음 도우미, 내비게이션 안내, 오디오북 생성 등 수많은 실무 시나리오에서 활용됩니다.

예를 들어, 뉴스 웹사이트에서 기사를 음성으로 들려주는 기능이나, 어린이 교육 앱에서 동화를 읽어주는 기능에 필수적이죠. 기존에는 Google TTS API 같은 유료 서비스를 사용하거나 클라이언트 측에서 브라우저의 Web Speech API를 써야 했다면, 이제는 FastAPI와 오픈소스 TTS 라이브러리로 직접 서버를 구축할 수 있습니다.

서버에서 처리하면 음질을 통제할 수 있고, 커스텀 보이스를 학습시킬 수도 있습니다. 이 엔드포인트의 핵심 특징은: (1) POST 메서드로 텍스트와 옵션을 받음, (2) 비동기로 TTS 변환 수행, (3) 오디오 파일을 저장하거나 스트리밍으로 반환.

이러한 특징들이 빠른 응답 속도와 확장 가능한 아키텍처를 가능하게 합니다.

코드 예제

from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from gtts import gTTS
import os
import uuid

router = APIRouter(prefix="/tts", tags=["TTS"])

class TTSRequest(BaseModel):
    text: str  # 변환할 텍스트
    language: str = "ko"  # 언어 코드 (기본값: 한국어)

@router.post("/generate")
async def generate_speech(request: TTSRequest):
    # 빈 텍스트 검증
    if not request.text.strip():
        raise HTTPException(status_code=400, detail="텍스트가 비어있습니다")

    # 고유한 파일명 생성
    filename = f"{uuid.uuid4()}.mp3"
    filepath = f"static/audio/{filename}"

    # TTS 변환 및 파일 저장
    tts = gTTS(text=request.text, lang=request.language)
    tts.save(filepath)

    # 오디오 파일 URL 반환
    return {"audio_url": f"/static/audio/{filename}", "duration": None}

설명

이것이 하는 일: 이 엔드포인트는 클라이언트로부터 텍스트를 받아 Google TTS 엔진으로 음성을 생성하고, 서버에 파일로 저장한 뒤 접근 가능한 URL을 돌려줍니다. 첫 번째로, Pydantic 모델 TTSRequest가 요청 데이터를 검증합니다.

text 필드는 필수이고 문자열이어야 하며, language는 선택적으로 기본값이 "ko"(한국어)입니다. FastAPI는 이 모델을 기반으로 자동으로 요청 본문을 파싱하고, 형식이 맞지 않으면 422 에러를 반환합니다.

예를 들어 text 필드가 없거나 숫자를 보내면 자동으로 거부되죠. 그 다음으로, generate_speech 함수가 실행되면서 먼저 텍스트가 비어있는지 확인합니다.

strip()으로 공백만 있는 경우도 걸러냅니다. 통과하면 uuid.uuid4()로 고유한 파일명을 생성하는데, 이렇게 하면 동시에 여러 요청이 와도 파일명이 겹치지 않습니다.

static/audio/ 폴더에 저장하도록 경로를 설정하죠. 세 번째로, gTTS 라이브러리가 실제 변환 작업을 수행합니다.

gTTS 객체를 생성할 때 text와 lang을 전달하고, save() 메서드로 지정한 경로에 MP3 파일을 저장합니다. 내부적으로는 Google의 TTS API를 호출하여 오디오 데이터를 가져온 뒤 파일로 쓰는 작업이 일어납니다.

이 과정은 네트워크 I/O를 포함하므로 시간이 걸릴 수 있습니다. 마지막으로, 생성된 파일의 URL을 JSON 응답으로 반환합니다.

/static/audio/는 FastAPI에서 정적 파일을 서빙하도록 설정한 경로입니다 (main.py에서 app.mount()로 설정). 클라이언트는 이 URL로 GET 요청을 보내 오디오 파일을 다운로드하거나 브라우저에서 재생할 수 있습니다.

duration 필드는 나중에 오디오 길이를 계산하는 로직을 추가할 수 있도록 준비해둔 것입니다. 여러분이 이 코드를 사용하면 몇 줄만으로 TTS 기능을 웹 서비스에 통합할 수 있습니다.

예를 들어 React 앱에서 fetch()로 이 엔드포인트를 호출하고, 받은 audio_url을 <audio> 태그의 src에 넣으면 즉시 재생됩니다. 또한 uuid를 사용했기 때문에 동시에 100명이 요청해도 문제없이 처리되며, 각 사용자는 자신만의 고유한 오디오 파일을 받습니다.

실전 팁

💡 gTTS는 간단하지만 인터넷 연결이 필요합니다. 오프라인 환경이나 더 나은 음질을 원한다면 Coqui TTS나 pyttsx3 같은 로컬 엔진으로 교체하세요. 서비스 레이어 패턴 덕분에 tts_service.py만 수정하면 됩니다.

💡 생성된 오디오 파일이 계속 쌓이면 디스크가 가득 찹니다. Celery나 APScheduler로 주기적인 정리 작업을 스케줄링하거나, 일정 시간 후 자동 삭제되도록 설정하세요 (예: 1시간 후 삭제).

💡 텍스트 길이 제한을 걸어두세요. request.text의 길이를 검사해서 5000자 이상이면 거부하거나, 긴 텍스트는 문단별로 나눠 여러 오디오로 생성하는 로직을 추가하면 서버 부하를 줄일 수 있습니다.

💡 FileResponse를 사용하면 파일을 직접 스트리밍할 수도 있습니다. return FileResponse(filepath, media_type="audio/mpeg")로 바꾸면 URL 대신 오디오 데이터를 바로 응답으로 보낼 수 있어, 클라이언트가 두 번 요청할 필요가 없어집니다.

💡 언어 코드 검증을 추가하세요. gTTS가 지원하는 언어 목록("ko", "en", "ja" 등)을 Enum으로 정의하고, Pydantic 모델에서 검증하면 잘못된 언어 코드로 인한 에러를 사전에 방지할 수 있습니다.


3. /chat 엔드포인트 구현 (대화형)

시작하며

여러분이 음성 비서를 만들 때 단순히 텍스트를 음성으로 바꾸는 것만으로는 부족합니다. "오늘 날씨 어때?", "내일 일정 알려줘" 같은 질문에 지능적으로 답변해야 진짜 비서처럼 느껴지죠.

이런 대화형 기능을 어떻게 구현해야 할까요? 실제로 많은 초보 개발자들이 LLM API를 호출하는 것까지는 성공하지만, 대화 맥락을 유지하지 못해 매번 새로운 대화처럼 느껴지는 문제를 겪습니다.

사용자가 "그거 뭐야?"라고 물었을 때 "그거"가 뭔지 모르는 AI는 쓸모가 없죠. 또한 LLM 응답을 받은 뒤 TTS로 변환하는 과정을 제대로 연결하지 못하는 경우도 많습니다.

바로 이럴 때 필요한 것이 대화 컨텍스트를 관리하고 LLM과 TTS를 자연스럽게 연결하는 /chat 엔드포인트입니다. 사용자의 메시지를 받아 AI가 답변을 생성하고, 그 답변을 음성으로 변환해 돌려주는 완전한 대화 사이클을 구현합니다.

개요

간단히 말해서, /chat 엔드포인트는 사용자의 텍스트 메시지를 받아 LLM으로 답변을 생성하고, 선택적으로 TTS를 적용하여 음성과 텍스트 두 가지 형태로 응답하는 API입니다. 마치 실제 사람과 대화하는 것처럼 자연스러운 상호작용을 만들어주죠.

이 엔드포인트가 왜 중요할까요? 단순 TTS는 정해진 텍스트만 읽어주지만, 대화형 AI는 사용자 질문에 맞춰 동적으로 답변을 생성합니다.

고객 지원 챗봇, 개인 비서 앱, 교육용 튜터, 노인을 위한 말동무 서비스 등 실무에서 정말 강력한 가치를 제공합니다. 예를 들어, 시각 장애인이 음성으로 질문하고 음성으로 답을 받는 완전한 핸즈프리 경험을 만들 수 있습니다.

기존에는 Dialogflow나 Amazon Lex 같은 유료 플랫폼을 사용하거나, 복잡한 NLU(자연어 이해) 파이프라인을 직접 구축해야 했다면, 이제는 OpenAI GPT 같은 LLM API만 연결하면 됩니다. 서버에서 대화 히스토리를 관리하면 이전 대화 맥락까지 유지할 수 있죠.

이 엔드포인트의 핵심 특징은: (1) LLM API 호출로 지능적인 응답 생성, (2) 세션 기반 대화 맥락 유지, (3) TTS 통합으로 음성 응답 제공. 이러한 특징들이 사용자에게 진짜 대화하는 듯한 경험을 선사하고, 접근성과 편의성을 극대화합니다.

코드 예제

from fastapi import APIRouter, Depends
from pydantic import BaseModel
import openai
import os

router = APIRouter(prefix="/chat", tags=["Chat"])

# 대화 히스토리 임시 저장 (실무에서는 Redis나 DB 사용)
conversations = {}

class ChatRequest(BaseModel):
    session_id: str  # 세션 식별자
    message: str  # 사용자 메시지
    use_voice: bool = False  # 음성 응답 여부

@router.post("/message")
async def chat_message(request: ChatRequest):
    # 세션별 대화 히스토리 로드
    history = conversations.get(request.session_id, [])
    history.append({"role": "user", "content": request.message})

    # OpenAI API 호출
    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=history
    )

    # AI 응답 추출 및 저장
    ai_message = response.choices[0].message.content
    history.append({"role": "assistant", "content": ai_message})
    conversations[request.session_id] = history[-10:]  # 최근 10개만 유지

    # 음성 응답 생성 (옵션)
    audio_url = None
    if request.use_voice:
        # TTS 서비스 호출 (여기서는 간단히 표시)
        audio_url = f"/tts/generate?text={ai_message}"

    return {"reply": ai_message, "audio_url": audio_url}

설명

이것이 하는 일: 이 엔드포인트는 사용자의 메시지를 받아 OpenAI GPT 모델로 답변을 생성하고, 세션별로 대화 히스토리를 관리하여 맥락을 유지하며, 요청 시 TTS로 음성 응답까지 만들어냅니다. 첫 번째로, ChatRequest 모델이 요청 데이터를 구조화합니다.

session_id는 같은 사용자의 대화를 추적하는 식별자로, UUID나 사용자 ID를 사용할 수 있습니다. message는 사용자가 입력한 텍스트이고, use_voice는 음성 응답을 원하는지 여부를 나타냅니다.

이렇게 분리하면 클라이언트가 텍스트만 원할 때는 TTS 비용을 아낄 수 있죠. 그 다음으로, conversations 딕셔너리에서 해당 세션의 대화 히스토리를 가져옵니다.

없으면 빈 리스트로 시작하고, 있으면 이전 대화 내용을 로드합니다. 사용자 메시지를 {"role": "user", "content": "..."} 형태로 추가하는데, 이는 OpenAI API의 메시지 형식입니다.

role은 "user", "assistant", "system" 중 하나이며, 이 구조로 대화의 흐름을 표현합니다. 세 번째로, OpenAI API를 호출하여 AI 응답을 생성합니다.

ChatCompletion.create()에 모델명과 전체 대화 히스토리를 전달하면, GPT가 맥락을 이해하고 적절한 답변을 만듭니다. 예를 들어 이전에 "나는 피자를 좋아해"라고 했고 지금 "그거 추천해줘"라고 하면, GPT는 "그거"가 피자라는 것을 알고 피자 맛집을 추천합니다.

응답에서 choices[0].message.content로 실제 텍스트를 추출하죠. 네 번째로, AI 응답을 히스토리에 추가하고 conversations에 저장합니다.

여기서 중요한 부분은 history[-10:]로 최근 10개 메시지만 유지한다는 점입니다. 대화가 길어지면 API 호출 비용이 증가하고 응답 시간도 느려지므로, 적절히 제한해야 합니다.

실무에서는 토큰 수를 계산하여 제한하는 것이 더 정확합니다. 마지막으로, use_voice가 True면 TTS 엔드포인트를 호출할 URL을 생성합니다.

실제로는 내부적으로 TTS 서비스를 호출하고 생성된 오디오 URL을 받아와야 하지만, 여기서는 간단히 표현했습니다. 클라이언트는 reply 텍스트를 화면에 표시하고, audio_url이 있으면 오디오를 재생할 수 있습니다.

여러분이 이 코드를 사용하면 몇 가지 강력한 이점을 얻습니다. 첫째, 세션 기반 대화 관리로 사용자마다 독립적인 대화 맥락을 유지할 수 있습니다.

둘째, LLM의 강력한 언어 이해 능력으로 복잡한 질문도 처리할 수 있습니다. 셋째, use_voice 플래그로 필요할 때만 음성을 생성하여 서버 자원을 효율적으로 사용합니다.

넷째, 대화 히스토리를 제한하여 API 비용과 응답 시간을 통제할 수 있습니다.

실전 팁

💡 conversations 딕셔너리는 서버 재시작 시 사라집니다. 실무에서는 Redis나 PostgreSQL에 세션 데이터를 저장하세요. Redis는 빠른 읽기/쓰기가 필요한 대화 히스토리에 특히 적합합니다.

💡 OpenAI API 호출은 시간이 걸리므로 timeout을 설정하세요. openai.api_timeout = 30처럼 설정하면 30초 후 예외를 발생시켜, 무한 대기를 방지합니다. 또한 try-except로 API 에러를 처리하여 사용자에게 친절한 에러 메시지를 보여주세요.

💡 시스템 프롬프트를 추가하면 AI의 성격과 역할을 정의할 수 있습니다. history 맨 앞에 {"role": "system", "content": "너는 친절한 음성 비서야"}를 넣으면, AI가 일관된 톤으로 답변합니다.

💡 대화 히스토리를 토큰 수 기준으로 제한하세요. tiktoken 라이브러리로 메시지의 토큰 수를 계산하고, 4000 토큰을 넘으면 오래된 메시지를 제거하면 gpt-3.5-turbo의 한계를 안전하게 유지할 수 있습니다.

💡 스트리밍 응답을 사용하면 사용자 경험이 크게 향상됩니다. openai.ChatCompletion.create()에 stream=True를 추가하고 FastAPI의 StreamingResponse를 사용하면, AI가 답변을 생성하는 동안 실시간으로 텍스트가 나타나 대기 시간이 짧게 느껴집니다.


4. WebSocket 연결로 실시간 통신

시작하며

여러분이 음성 비서를 사용할 때 가장 답답한 순간이 언제인가요? 바로 말을 하고 나서 한참을 기다려야 답변이 오는 상황입니다.

HTTP 요청-응답 방식은 매번 연결을 새로 만들어야 해서 지연이 발생하고, 실시간으로 대화하는 느낌이 들지 않죠. 실제로 많은 개발자들이 REST API로 음성 비서를 만들었다가, 사용자들이 "너무 느리다", "끊기는 느낌이다"라는 피드백을 받습니다.

특히 TTS 음성을 듣는 도중에 다음 질문을 하거나, 스트리밍으로 긴 답변을 받고 싶을 때 HTTP만으로는 한계가 명확합니다. 바로 이럴 때 필요한 것이 WebSocket 연결입니다.

한 번 연결하면 양방향으로 계속 데이터를 주고받을 수 있어, 실시간 채팅, 라이브 음성 인식, 스트리밍 응답 같은 고급 기능을 구현할 수 있습니다.

개요

간단히 말해서, WebSocket은 클라이언트와 서버 사이에 지속적인 연결을 만들어 실시간으로 메시지를 주고받을 수 있게 해주는 프로토콜입니다. HTTP가 "질문하고 답 받고 끝"이라면, WebSocket은 "전화를 걸어놓고 계속 대화"하는 것과 같습니다.

음성 비서 서버에서 WebSocket이 왜 중요할까요? 사용자가 말하는 동안 실시간으로 음성 인식 결과를 보여주거나, AI가 긴 답변을 생성하는 동안 단어 단위로 스트리밍하거나, 여러 사용자가 동시에 대화방에 접속하는 기능을 만들 수 있습니다.

예를 들어, 고객 지원 음성봇에서 고객이 말하는 즉시 텍스트로 변환되는 것을 실시간으로 보여주면 신뢰도가 크게 높아집니다. 기존에는 Socket.io나 별도의 WebSocket 서버를 구축해야 했다면, FastAPI는 WebSocket을 기본으로 지원하여 REST API와 WebSocket을 하나의 애플리케이션에서 함께 관리할 수 있습니다.

같은 포트, 같은 인증, 같은 로직을 공유하죠. WebSocket 엔드포인트의 핵심 특징은: (1) 지속적인 양방향 연결, (2) 낮은 지연시간과 오버헤드, (3) 이벤트 기반 메시지 처리.

이러한 특징들이 실시간 협업, 게임, 금융 거래, 실시간 알림 등 시간에 민감한 애플리케이션을 가능하게 합니다.

코드 예제

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Dict
import json

router = APIRouter()

# 활성 WebSocket 연결 관리
active_connections: Dict[str, WebSocket] = {}

@router.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
    # 연결 수락
    await websocket.accept()
    active_connections[session_id] = websocket

    try:
        while True:
            # 클라이언트로부터 메시지 수신
            data = await websocket.receive_text()
            message = json.loads(data)

            # 메시지 타입에 따라 처리
            if message["type"] == "chat":
                # LLM 응답 생성 (예시)
                reply = f"Echo: {message['content']}"

                # 응답 전송
                await websocket.send_json({
                    "type": "response",
                    "content": reply
                })

    except WebSocketDisconnect:
        # 연결 종료 시 정리
        del active_connections[session_id]
        print(f"Session {session_id} disconnected")

설명

이것이 하는 일: 이 WebSocket 엔드포인트는 클라이언트와 지속적인 연결을 맺고, 클라이언트가 보내는 메시지를 실시간으로 받아 처리한 뒤, 즉시 응답을 돌려보냅니다. 첫 번째로, @router.websocket 데코레이터가 WebSocket 엔드포인트를 정의합니다.

경로에 {session_id}를 포함하여 각 사용자를 구분할 수 있게 했습니다. websocket.accept()를 호출하면 클라이언트의 연결 요청을 수락하고, 핸드셰이크 과정이 완료됩니다.

이후부터는 양방향 통신이 가능한 상태가 되죠. active_connections 딕셔너리에 이 연결을 저장하여 나중에 서버에서 먼저 메시지를 보낼 수도 있습니다.

그 다음으로, while True 루프가 연결이 유지되는 동안 계속 실행됩니다. websocket.receive_text()는 클라이언트가 메시지를 보낼 때까지 비동기로 대기합니다.

메시지가 오면 json.loads()로 파싱하여 구조화된 데이터로 만듭니다. 이렇게 하면 type 필드로 메시지 종류를 구분할 수 있어, "chat", "tts", "control" 같은 다양한 명령을 처리할 수 있습니다.

세 번째로, 메시지 타입에 따른 분기 처리가 일어납니다. 여기서는 간단히 "chat" 타입일 때 에코 응답을 만들었지만, 실제로는 여기서 LLM 서비스를 호출하거나 TTS를 생성할 수 있습니다.

중요한 점은 websocket.send_json()으로 응답을 보낼 때 클라이언트가 즉시 받을 수 있다는 것입니다. HTTP처럼 새 요청을 보낼 필요가 없죠.

네 번째로, WebSocketDisconnect 예외 처리가 연결 종료를 감지합니다. 사용자가 브라우저를 닫거나 네트워크가 끊기면 이 예외가 발생하고, active_connections에서 해당 세션을 제거하여 메모리 누수를 방지합니다.

또한 로그를 남겨 연결 상태를 모니터링할 수 있습니다. 마지막으로, 이 구조는 여러 클라이언트가 동시에 연결할 수 있도록 설계되었습니다.

각 session_id마다 독립적인 WebSocket 객체가 생성되고, 각자의 while 루프가 돌아갑니다. active_connections를 사용하면 모든 연결된 클라이언트에게 브로드캐스트 메시지를 보내는 것도 가능합니다 (예: for ws in active_connections.values(): await ws.send_json(...)).

여러분이 이 코드를 사용하면 진정한 실시간 애플리케이션을 만들 수 있습니다. 예를 들어 음성 인식 중간 결과를 계속 업데이트하거나, AI가 답변을 생성하는 동안 단어가 하나씩 나타나는 타이핑 효과를 만들거나, 여러 사용자가 같은 음성 비서를 동시에 사용하면서 서로의 상태를 볼 수 있습니다.

또한 연결이 유지되므로 서버에서 먼저 푸시 알림을 보낼 수도 있어, "배터리 부족합니다" 같은 시스템 메시지를 능동적으로 전달할 수 있습니다.

실전 팁

💡 WebSocket 연결은 로드밸런서를 통과할 때 sticky session이 필요합니다. Nginx나 AWS ALB에서 연결을 유지하도록 설정하지 않으면, 요청마다 다른 서버로 가서 연결이 끊깁니다.

💡 하트비트(ping/pong)를 구현하세요. 주기적으로 작은 메시지를 보내 연결이 살아있는지 확인하면, 죽은 연결을 빠르게 감지하고 정리할 수 있습니다. asyncio.create_task()로 백그라운드에서 30초마다 ping을 보내는 작업을 만드세요.

💡 메시지 크기 제한을 설정하세요. 악의적인 클라이언트가 매우 큰 메시지를 보내면 서버 메모리가 터질 수 있습니다. FastAPI의 WebSocket 설정에서 max_size를 지정하거나, 받은 데이터 크기를 검사하세요.

💡 인증을 추가하세요. WebSocket은 HTTP처럼 매번 헤더를 보낼 수 없으므로, 연결 시 쿼리 파라미터나 첫 메시지로 토큰을 전달받아 검증하세요. 인증 실패 시 await websocket.close(code=1008)로 연결을 거부합니다.

💡 Redis Pub/Sub을 사용하면 여러 서버 인스턴스 간 메시지를 동기화할 수 있습니다. 한 서버의 WebSocket으로 메시지가 오면 Redis에 발행하고, 다른 서버들이 구독하여 자신의 클라이언트들에게 전달하면 수평 확장이 가능합니다.


5. Docker 컨테이너화 및 배포

시작하며

여러분이 로컬에서 완벽하게 작동하는 FastAPI 음성 비서 서버를 만들었다고 해봅시다. 이제 실제 사용자들에게 서비스하려면 어떻게 해야 할까요?

서버에 Python을 설치하고, 라이브러리를 설치하고, 환경 변수를 설정하고... 생각만 해도 복잡하죠.

실제로 많은 개발자들이 배포 단계에서 "내 컴퓨터에서는 됐는데 서버에서는 안 돼요"라는 악명 높은 문제를 겪습니다. Python 버전이 달라서, 시스템 라이브러리가 없어서, 권한 문제로 인해 수많은 시행착오를 반복하죠.

또한 서버 환경을 오염시켜 다른 프로젝트와 충돌이 일어나기도 합니다. 바로 이럴 때 필요한 것이 Docker 컨테이너화입니다.

여러분의 애플리케이션과 모든 의존성을 하나의 패키지로 묶어, 어떤 환경에서든 동일하게 작동하도록 보장합니다. "내 컴퓨터에서 되면 어디서든 된다"를 현실로 만들어주죠.

개요

간단히 말해서, Docker 컨테이너화는 여러분의 애플리케이션과 실행에 필요한 모든 것(Python, 라이브러리, 시스템 도구)을 하나의 독립적인 박스에 담는 것입니다. 이 박스는 어느 서버에 옮겨도 똑같이 작동합니다.

음성 비서 서버를 Docker로 컨테이너화하는 것이 왜 중요할까요? 첫째, 일관성 있는 배포 환경을 보장합니다.

개발 환경, 테스트 서버, 프로덕션 서버가 모두 동일한 이미지를 사용하므로 "환경 차이" 문제가 사라집니다. 둘째, 의존성 격리로 다른 애플리케이션과 충돌하지 않습니다.

예를 들어, 이 프로젝트는 Python 3.9를 쓰고 다른 프로젝트는 3.11을 써도 서로 영향을 주지 않죠. 셋째, 쉬운 확장과 관리가 가능합니다.

트래픽이 증가하면 같은 컨테이너를 여러 개 띄우기만 하면 됩니다. 기존에는 가상 머신을 사용하거나 서버마다 수동으로 환경을 구축해야 했다면, Docker는 가볍고 빠른 컨테이너로 동일한 효과를 냅니다.

가상 머신은 부팅에 몇 분이 걸리지만, Docker 컨테이너는 몇 초면 시작됩니다. Docker 컨테이너화의 핵심 특징은: (1) Dockerfile로 환경 정의를 코드화, (2) 이미지 레이어링으로 효율적인 저장과 배포, (3) 컨테이너 오케스트레이션(Kubernetes 등)과 통합 가능.

이러한 특징들이 현대적인 DevOps와 CI/CD 파이프라인의 기반이 됩니다.

코드 예제

# Dockerfile - FastAPI 음성 비서 서버용
FROM python:3.9-slim

# 작업 디렉토리 설정
WORKDIR /app

# 시스템 의존성 설치 (오디오 처리용)
RUN apt-get update && apt-get install -y \
    ffmpeg \
    libsndfile1 \
    && rm -rf /var/lib/apt/lists/*

# Python 의존성 파일 복사 및 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 애플리케이션 코드 복사
COPY ./app /app/app

# 정적 파일 디렉토리 생성
RUN mkdir -p /app/static/audio

# 포트 노출
EXPOSE 8000

# 서버 실행
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

설명

이것이 하는 일: 이 Dockerfile은 FastAPI 음성 비서 서버를 실행하는 데 필요한 모든 환경을 정의하고, Docker 이미지로 빌드할 수 있게 해줍니다. 첫 번째로, FROM python:3.9-slim이 베이스 이미지를 선택합니다.

python:3.9-slim은 Python 3.9가 설치된 가벼운 Debian 리눅스 이미지입니다. "slim" 버전은 불필요한 패키지가 제거되어 이미지 크기가 작고 보안 취약점도 적습니다.

이 한 줄로 Python 환경 설정이 끝나죠. WORKDIR /app은 컨테이너 내부에서 작업할 디렉토리를 /app으로 지정하여, 이후 모든 명령이 이 경로에서 실행됩니다.

그 다음으로, RUN apt-get으로 시스템 레벨 의존성을 설치합니다. ffmpeg는 오디오 파일 변환에 필요하고, libsndfile1은 일부 TTS 라이브러리가 오디오를 읽고 쓰는 데 사용합니다.

&& rm -rf /var/lib/apt/lists/*는 패키지 캐시를 삭제하여 이미지 크기를 줄이는 모범 사례입니다. 이 단계에서 에러가 나면 대부분 시스템 패키지명이 잘못되었거나 저장소 업데이트가 필요한 경우입니다.

세 번째로, COPY requirements.txt .와 RUN pip install이 Python 의존성을 설치합니다. 여기서 중요한 최적화는 requirements.txt를 먼저 복사하고 설치한 뒤, 나중에 코드를 복사하는 순서입니다.

Docker는 레이어를 캐싱하므로, 코드가 바뀌어도 requirements.txt가 안 바뀌면 이 단계를 건너뛰어 빌드 시간이 크게 단축됩니다. --no-cache-dir는 pip 캐시를 저장하지 않아 이미지 크기를 줄입니다.

네 번째로, COPY ./app /app/app이 실제 애플리케이션 코드를 복사합니다. 로컬의 ./app 폴더를 컨테이너의 /app/app으로 복사하는 것이죠.

.dockerignore 파일을 만들어 pycache, .git, .env 같은 불필요한 파일을 제외하면 더 효율적입니다. RUN mkdir -p로 정적 파일 디렉토리를 미리 만들어두면 TTS가 오디오 파일을 저장할 때 "디렉토리 없음" 에러를 방지할 수 있습니다.

마지막으로, EXPOSE 8000이 컨테이너가 8000번 포트를 사용한다고 선언하고 (실제로 열지는 않음, 문서화 목적), CMD로 컨테이너가 시작될 때 실행할 명령을 지정합니다. uvicorn은 FastAPI를 실행하는 ASGI 서버이고, --host 0.0.0.0은 외부 접속을 허용하며 (기본값 127.0.0.1은 컨테이너 내부만 허용), --port 8000은 포트를 명시합니다.

여러분이 이 Dockerfile을 사용하면 docker build -t voice-assistant . 명령으로 이미지를 만들고, docker run -p 8000:8000 voice-assistant로 컨테이너를 실행할 수 있습니다.

한 번 이미지를 만들면 어떤 서버든 동일하게 배포할 수 있고, Docker Hub에 올려 팀원들과 공유할 수도 있습니다. AWS, GCP, Azure 같은 클라우드에서도 컨테이너 서비스(ECS, GKE, AKS)로 쉽게 배포됩니다.

또한 docker-compose를 사용하면 데이터베이스, Redis 같은 다른 서비스와 함께 여러 컨테이너를 한 번에 관리할 수 있습니다.

실전 팁

💡 멀티스테이지 빌드를 사용하면 이미지 크기를 극적으로 줄일 수 있습니다. 첫 번째 스테이지에서 의존성을 설치하고, 두 번째 스테이지에서 필요한 것만 복사하면 빌드 도구는 최종 이미지에 포함되지 않습니다.

💡 환경 변수는 .env 파일이 아닌 docker run -e 옵션이나 docker-compose의 environment로 전달하세요. 민감한 정보를 이미지에 포함시키면 이미지를 공유할 때 노출됩니다. AWS Secrets Manager나 Kubernetes Secrets를 사용하면 더 안전합니다.

💡 헬스 체크를 추가하세요. Dockerfile에 HEALTHCHECK 명령을 넣으면 컨테이너가 정상 작동하는지 주기적으로 확인하고, 문제 발생 시 자동으로 재시작할 수 있습니다 (예: HEALTHCHECK CMD curl -f http://localhost:8000/health || exit 1).

💡 프로덕션에서는 root 사용자로 실행하지 마세요. Dockerfile에서 RUN useradd -m myuser와 USER myuser를 추가하여 일반 사용자로 앱을 실행하면 보안이 크게 향상됩니다.

💡 볼륨을 사용하여 데이터 영속성을 보장하세요. 생성된 오디오 파일은 컨테이너 내부에 저장하면 컨테이너 재시작 시 사라지므로, docker run -v ./data:/app/static처럼 호스트 디렉토리나 Docker 볼륨을 마운트하여 데이터를 보존하세요.


6. HTTPS 설정 및 보안 고려사항

시작하며

여러분의 음성 비서 서버가 드디어 배포되었습니다! 하지만 잠깐, 브라우저에서 마이크 권한을 요청했더니 "안전하지 않은 연결"이라며 거부당했습니다.

또한 API 키가 네트워크 요청에 평문으로 노출되고, 누구나 여러분의 TTS 엔드포인트를 무제한으로 호출할 수 있는 상태죠. 실제로 많은 초보 개발자들이 기능 구현에만 집중하다가 보안을 소홀히 합니다.

HTTP로 서비스하면 중간에서 데이터를 가로채거나 변조할 수 있고, 인증 없이 엔드포인트를 열어두면 악의적인 사용자가 서버 자원을 남용하거나 민감한 데이터를 탈취할 수 있습니다. 특히 음성 데이터는 개인정보이므로 법적 문제로 이어질 수도 있죠.

바로 이럴 때 필요한 것이 HTTPS 설정과 종합적인 보안 조치입니다. SSL/TLS로 통신을 암호화하고, 인증과 권한 부여로 접근을 통제하며, 입력 검증과 Rate Limiting으로 악용을 방지해야 안전한 서비스를 제공할 수 있습니다.

개요

간단히 말해서, HTTPS는 클라이언트와 서버 간의 모든 통신을 암호화하여 제3자가 내용을 볼 수 없게 만드는 프로토콜입니다. HTTP가 엽서라면, HTTPS는 봉인된 편지와 같습니다.

음성 비서 서버에서 보안이 왜 중요할까요? 첫째, 브라우저는 마이크와 카메라 같은 민감한 권한을 HTTPS 사이트에만 허용합니다.

HTTP로는 음성 입력 자체가 불가능하죠. 둘째, 사용자의 음성 데이터와 대화 내용은 개인정보입니다.

암호화 없이 전송하면 공공 와이파이에서 누구나 가로챌 수 있습니다. 셋째, API 키나 인증 토큰이 평문으로 노출되면 서버가 해킹당할 수 있습니다.

예를 들어, OpenAI API 키가 누출되면 여러분 계정으로 무제한 요청이 발생해 엄청난 비용이 청구됩니다. 기존에는 SSL 인증서를 구매하고 복잡한 설정을 해야 했다면, 이제는 Let's Encrypt로 무료 인증서를 자동으로 발급받고, Nginx나 Caddy 같은 리버스 프록시로 쉽게 HTTPS를 적용할 수 있습니다.

Certbot이 인증서 갱신도 자동화해주죠. HTTPS와 보안의 핵심 특징은: (1) SSL/TLS 암호화로 도청 방지, (2) JWT나 OAuth로 인증 및 권한 관리, (3) Rate Limiting과 입력 검증으로 악용 방지.

이러한 특징들이 사용자 신뢰를 얻고, 규제 준수를 보장하며, 서비스 안정성을 유지하게 해줍니다.

코드 예제

# docker-compose.yml - Nginx와 Let's Encrypt 통합
version: '3.8'

services:
  app:
    build: .
    container_name: voice_assistant
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    volumes:
      - ./static:/app/static
    networks:
      - app_network

  nginx:
    image: nginx:alpine
    container_name: nginx_proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    depends_on:
      - app
    networks:
      - app_network

  certbot:
    image: certbot/certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

networks:
  app_network:

설명

이것이 하는 일: 이 docker-compose 설정은 FastAPI 앱, Nginx 리버스 프록시, Certbot 인증서 관리자를 하나의 스택으로 묶어 HTTPS를 자동으로 설정하고 유지합니다. 첫 번째로, app 서비스가 여러분의 FastAPI 애플리케이션을 실행합니다.

여기서 중요한 부분은 environment로 환경 변수를 전달하는 것인데, ${OPENAI_API_KEY}는 호스트의 .env 파일이나 시스템 환경 변수에서 값을 가져옵니다. 이렇게 하면 민감한 정보를 코드나 이미지에 포함시키지 않고 안전하게 주입할 수 있습니다.

volumes로 static 폴더를 마운트하여 생성된 오디오 파일이 컨테이너 재시작 후에도 유지되도록 하죠. 그 다음으로, nginx 서비스가 리버스 프록시 역할을 합니다.

80번 포트(HTTP)와 443번 포트(HTTPS)를 외부에 노출하고, nginx.conf 설정 파일을 컨테이너에 마운트합니다. 이 설정 파일에서는 HTTP 요청을 HTTPS로 리다이렉트하고, HTTPS 요청을 app 서비스의 8000번 포트로 프록시합니다.

예를 들어, 사용자가 https://yourdomain.com/tts/generate를 요청하면 Nginx가 받아서 http://app:8000/tts/generate로 전달하는 식이죠. certbot/conf와 certbot/www 볼륨을 공유하여 SSL 인증서와 검증 파일에 접근합니다.

세 번째로, certbot 서비스가 Let's Encrypt 인증서를 관리합니다. entrypoint에서 certbot renew 명령을 12시간마다 실행하여 인증서가 만료되기 전에 자동으로 갱신합니다.

Let's Encrypt 인증서는 90일마다 만료되므로, 이런 자동화가 필수적입니다. 첫 인증서 발급은 certbot certonly --webroot -w /var/www/certbot -d yourdomain.com 명령을 수동으로 실행하면 되고, 이후 갱신은 자동으로 일어납니다.

네 번째로, networks를 사용하여 세 컨테이너가 같은 네트워크에 속하게 합니다. 이렇게 하면 nginx에서 app으로 접근할 때 컨테이너 이름(app)을 호스트명으로 사용할 수 있습니다.

Docker의 내장 DNS가 이름 해석을 해주기 때문이죠. 외부에서는 nginx만 접근 가능하고 app은 숨겨져 보안이 강화됩니다.

마지막으로, 실제 nginx.conf 파일에서는 SSL 설정을 추가해야 합니다. ssl_certificate와 ssl_certificate_key로 인증서 경로를 지정하고, ssl_protocols TLSv1.2 TLSv1.3처럼 안전한 프로토콜만 허용합니다.

또한 proxy_set_header로 X-Forwarded-For 같은 헤더를 전달하여 FastAPI가 실제 클라이언트 IP를 알 수 있게 하고, WebSocket 업그레이드를 위해 Upgrade와 Connection 헤더도 설정합니다. 여러분이 이 설정을 사용하면 도메인만 있으면 몇 분 안에 HTTPS를 적용할 수 있습니다.

Let's Encrypt가 무료이고 자동 갱신되므로 운영 부담이 거의 없습니다. Nginx가 앞단에서 트래픽을 처리하므로 정적 파일 서빙, gzip 압축, 캐싱 같은 최적화도 쉽게 추가할 수 있습니다.

또한 Nginx에서 Rate Limiting(limit_req)을 설정하면 DDoS 공격을 어느 정도 방어할 수 있고, IP 블랙리스트나 GeoIP 필터링도 가능합니다.

실전 팁

💡 FastAPI에 인증 미들웨어를 추가하세요. from fastapi.security import HTTPBearer로 Bearer 토큰을 검증하고, JWT를 디코딩하여 사용자 식별과 권한 확인을 수행하면 무단 접근을 막을 수 있습니다. /health 같은 공개 엔드포인트는 예외 처리하세요.

💡 CORS 설정을 제한적으로 하세요. FastAPI의 CORSMiddleware에서 allow_origins를 "*" 대신 실제 프론트엔드 도메인 리스트로 지정하면, 다른 사이트에서 여러분의 API를 호출하는 것을 방지합니다.

💡 입력 데이터 검증을 철저히 하세요. Pydantic 모델에 Field(max_length=5000) 같은 제약을 추가하고, 정규표현식으로 허용된 문자만 받도록 하면 SQL Injection이나 XSS 공격을 예방합니다. 특히 사용자 입력을 파일명에 사용할 때는 절대 그대로 쓰지 마세요.

💡 Rate Limiting을 API 레벨에서도 구현하세요. slowapi 라이브러리로 IP별, 사용자별 요청 제한을 걸면 (예: 1분에 10회), 악의적인 스크립트가 TTS를 무제한 호출하여 서버 자원을 고갈시키거나 LLM API 비용을 폭증시키는 것을 막을 수 있습니다.

💡 로깅과 모니터링을 설정하세요. 모든 API 요청, 에러, 인증 실패를 로그로 남기고 (Loguru나 structlog 사용), Sentry로 에러를 실시간 추적하며, Prometheus + Grafana로 메트릭을 시각화하면 보안 사고를 빠르게 감지하고 대응할 수 있습니다.


#FastAPI#TTS#WebSocket#Docker#VoiceAssistant#AI,TTS,음성합성,VoiceCloning,LLM,딥러닝

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.