Blog

[AI딥페이크] 6. 딥페이크 탐지 프로젝트(2) - Inference API + Frontend + Backend → Docker 애플리케이션 통합

Category
Author
PinOnMain
1 more property
AI 추론 API를 구축하고(FastAPI) 메인 서비스 애플리케이션과의 연결을 통해 하나의 애플리케이션으로 동작하도록 통합합니다.
Table of Content

6일차: 최종 시스템 통합 및 데모 (FastAPI)

부제: 딥보이스/딥페이크 탐지 모델을 활용한 추론 API 서버 구축 및 전체 통합

Agenda: 6일차 학습 목표 및 일정 (8H)

모듈 1 (이론/실습): FastAPI 서버 환경 설정 및 프로젝트 구조 구성
Python 환경 설정 및 FastAPI, Uvicorn, TensorFlow 라이브러리 설치
models/ 폴더에 5일차 모델 파일 배치
모듈 2 (실습): 딥보이스 탐지 API 개발 (FastAPI)
model_audio.h5 로드 및 추론 함수 구현
/deepfake/audio 엔드포인트 구현 (음성 파일 수신 및 진위 판별)
모듈 3 (실습): 딥페이크 이미지 탐지 API 개발 (FastAPI)
model_image.h5 로드 및 추론 함수 구현
/deepfake/image 엔드포인트 구현 (이미지 파일 수신 및 진위 판별)
모듈 4 (실습): 전체 프론트/백엔드 시스템 통합 및 연결
3일차 React (Frontend) <-> 4일차 NestJS (Backend) <-> 6일차 FastAPI (AI Inference)
모듈 5 (최종): Docker 가상 컨테이너 기술을 활용한 통합 애플리케이션 구축 프로젝트 데모 및 Q&A

모듈 1: FastAPI 서버 환경 설정 및 프로젝트 구조

1. 환경 설정 및 라이브러리 설치

목표: FastAPI 웹 서버 환경을 구축하고, TensorFlow 모델을 로드할 수 있도록 준비.
Python 설치 (3.12→ colab 동일 버전 환경)
Use admin privileges when installing py.exe 체크
Add python.exe to PATH 체크
설치 후 python --version
필수 라이브러리: fastapi, uvicorn, tensorflow, librosa, opencv-python.
1단계: VS Code 환경 설정 및 프로젝트 초기화
1-1. 프로젝트 폴더 생성
detect-inference
Plain Text
복사
폴더 생성 후 VS Code에서 엽니다.
1-2. 가상 환경 생성
python -m venv venv
Plain Text
복사
1-3. 가상 환경 활성화
(Windows)
.\venv\Scripts\activate
Plain Text
복사
(MacOS/Linux) 또는
source venv/bin/activate
Plain Text
복사
1-4. 모델 폴더 생성
models/
Plain Text
복사
폴더를 생성하고, 5일차에 확보한 model_audio.h5 , model_image.h5를 여기에 배치합니다.
requirements.txt 생성 및 아래 내용 삽입 (필수 라이브러리 기입)
fastapi uvicorn tensorflow librosa opencv-python python-multipart Pillow
Python
복사
해당 파일을 기준으로 패키지 설치
파이선패키지매니저 업데이트
python.exe -m pip install --upgrade pip
패키지(라이브러리) 설치
pip install -r requirements.txt
설치 완료 후 venv/Lib 안에 라이브러리들이 설치되어 있다.

2. 프로젝트 디렉토리 구조

FastAPI 서버의 뼈대 구성:
/deepfake-api/ ├── main.py # FastAPI 메인 서버 파일 ├── models/ │ ├── model_audio.h5 # 5일차 결과물 (음성 모델) │ └── model_image.h5 # 5일차 결과물 (이미지 모델) └── requirements.txt
Plain Text
복사
FastAPI 메인실행 함수 생성
root/main.py
from fastapi import FastAPI app = FastAPI() @app.get("/") async def root(): return {"message": "Hello World"}
Python
복사
간단히 FastAPI 로 기본 API 서버를 만들어 봅니다.
실행 명령어는
uvicorn main:app --reload --host localhost --port 8000
Python
복사
브라우저로 localhost:8000 으로 접속하면 응답으로 Hello World 확인
버전 호환성의 문제를 해결하기 위해 Colab의 모델 생성 버전을 낮춤

3. 모델 초기화

FastAPI 서버가 시작될 때, 5일차에 저장한 두 모델을 메모리에 미리 로드하여 추론 속도를 확보.
코드 핵심: tf.keras.models.load_model() 함수를 사용하여 .h5 파일을 로드.
모델 로드 확인
main.py 수정
import tensorflow as tf from fastapi import FastAPI, UploadFile, File from contextlib import asynccontextmanager import numpy as np # 1. 전역 변수로 두 모델 선언 image_model = None audio_model = None @asynccontextmanager async def lifespan(app: FastAPI): # 전역 변수를 가져옵니다 global image_model, audio_model print("\n========== 서버 시작: 모델 로드 프로세스 ==========") # --- [1] 이미지 모델 로드 --- try: print("1. 이미지 모델(model_image.h5) 로드 중...") image_model = tf.keras.models.load_model("models/model_image.h5", compile=False) print(" ✅ 이미지 모델 로드 성공!") except Exception as e: print(f" ❌ 이미지 모델 로드 실패: {e}") # --- [2] 오디오 모델 로드 --- try: print("2. 오디오 모델(model_audio.h5) 로드 중...") audio_model = tf.keras.models.load_model("models/model_audio.h5", compile=False) print(" ✅ 오디오 모델 로드 성공!") except Exception as e: print(f" ❌ 오디오 모델 로드 실패: {e}") print("==================================================\n") yield # 서버 실행 중... # --- [3] 종료 시 리소스 해제 --- image_model = None audio_model = None print("모든 모델 리소스 해제 완료.") app = FastAPI(lifespan=lifespan) @app.get("/") def read_root(): # 현재 로드 상태를 보여주는 메인 페이지 return { "status": "Server is running", "models_status": { "image_model": "Loaded" if image_model else "Not Loaded", "audio_model": "Loaded" if audio_model else "Not Loaded" } } # --------------------------------------------------------- # 추론용 API # --------------------------------------------------------- @app.post("/predict/image") async def predict_image(file: UploadFile = File(...)): if image_model is None: return {"error": "이미지 모델이 로드되지 않았습니다."} # 여기에 이미지 전처리 및 model.predict(image_model) 로직 작성 return {"message": "이미지 모델 추론 기능 구현 필요"} @app.post("/predict/audio") async def predict_audio(file: UploadFile = File(...)): if audio_model is None: return {"error": "오디오 모델이 로드되지 않았습니다."} # 여기에 오디오 전처리(Librosa 등) 및 model.predict(audio_model) 로직 작성 return {"message": "오디오 모델 추론 기능 구현 필요"}
Python
복사

모듈 2: 딥보이스 탐지 API 개발 (FastAPI)

1. 음성 데이터 전처리 함수 구현

5일차 복습: Colab에서 사용했던 librosa 기반의 MFCC 추출 로직을 Python 함수로 재구현.
역할: POST로 수신된 오디오 바이트 스트림을 모델이 요구하는 'MFCC 특징 벡터 (Numpy 배열)' 형태로 변환.
전처리용 라이브러리 추가
pip install librosa soundfile
Python
복사
전처리 부분 코드 추가 main.py
import tensorflow as tf from fastapi import FastAPI, UploadFile, File from contextlib import asynccontextmanager import numpy as np import io import librosa # 1. 전역 변수로 두 모델 선언 image_model = None audio_model = None # =============PREPROCESS_AUDIO=============== def preprocess_audio(audio_bytes): try: # 1. 바이트 데이터를 오디오 로드 # sr=16000 (학습 데이터셋과 동일해야 함) audio_file = io.BytesIO(audio_bytes) y, sr = librosa.load(audio_file, sr=16000) # 2. MFCC 추출 (n_mfcc=40 학습 설정과 동일해야 함) # 기본 출력 shape: (n_mfcc, time) -> 예: (40, 92) mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=40) # 3. 길이(Time) 고정: 모델이 100을 요구함 target_length = 100 current_length = mfcc.shape[1] if current_length < target_length: # 길이가 짧으면 뒤를 0으로 채움 (Padding) pad_width = target_length - current_length # ((0,0), (0, pad_width)) -> 앞 차원은 그대로, 뒤 차원(Time)만 패딩 mfcc = np.pad(mfcc, ((0, 0), (0, pad_width)), mode='constant') else: # 길이가 길면 100까지만 자름 (Truncating) mfcc = mfcc[:, :target_length] # 4. 차원 변환 (Transpose) # 현재 (40, 100) -> 모델 요구 (100, 40) mfcc = mfcc.T # 5. 배치 차원 추가 # (100, 40) -> (1, 100, 40) mfcc = np.expand_dims(mfcc, axis=0) return mfcc except Exception as e: print(f"전처리 오류: {e}") return None # ========================================== @asynccontextmanager async def lifespan(app: FastAPI): ...
Python
복사

2. /deepfake/audio 엔드포인트 구현

메서드: POST
역할: 사용자로부터 음성 파일(바이트 스트림)을 받아 진위 여부를 판별하여 JSON 응답을 반환.
반환 형식: {"is_deepfake": boolean, "confidence": float} (예: {"is_deepfake": true, "confidence": 0.98})
엔드포인트 비지니스 로직구현
main.py
... # --------------------------------------------------------- # 추론용 API # --------------------------------------------------------- @app.post("/predict/image") ... # --- [모듈 2] 2. /predict/audio 엔드포인트 구현 --- @app.post("/predict/audio") async def predict_audio(file: UploadFile = File(...)): """ 음성 파일을 받아 딥보이스 여부를 판별합니다. """ # 1. 모델 로드 확인 if audio_model is None: return {"error": "오디오 모델이 로드되지 않았습니다. 서버 로그를 확인하세요."} try: # 2. 파일 읽기 (바이트 단위) audio_bytes = await file.read() # 3. 전처리 (MFCC 추출) model_input = preprocess_audio(audio_bytes) if model_input is None: return {"error": "오디오 처리 중 오류가 발생했습니다. 파일 형식을 확인하세요."} # 4. 모델 추론 (predict) # 결과는 [[0.1234]] 형태의 2차원 배열로 나옴 prediction = audio_model.predict(model_input) confidence = float(prediction[0][0]) # 0~1 사이의 확률 값 # 5. 결과 판별 (임계값 0.5) # 0.5 이상이면 Fake(가짜), 미만이면 Real(진짜) is_deepfake = confidence < 0.5 return { "filename": file.filename, "is_deepfake": is_deepfake, "confidence": f"{confidence:.4f}", # 소수점 4자리까지 "message": "딥보이스 탐지 완료" } except Exception as e: return {"error": f"서버 내부 오류: {str(e)}"}
Python
복사
탐지 결과 테스트
REAL 파일
FAKE 파일

모듈 3: 딥페이크 이미지 탐지 API 개발 (FastAPI)

1. 이미지 데이터 전처리 함수 구현

5일차 복습: Colab에서 사용했던 OpenCV 기반의 크기 조정 (Resizing)정규화 (Normalization) 로직을 Python 함수로 재구현.
역할: POST로 수신된 이미지 파일을 모델이 요구하는 '픽셀 배열' 형태로 변환.
전처리용 라이브러리 추가
pip install pillow
Python
복사
전처리 부분 코드 추가 main.py
import tensorflow as tf from fastapi import FastAPI, UploadFile, File from contextlib import asynccontextmanager import cv2 import numpy as np import io import librosa from PIL import Image # ========================================== # [설정] 이미지 모델의 입력 크기 (가로, 세로) TARGET_IMG_SIZE = (299, 299) # Xception 기본 크기 face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') # 1. 전역 변수로 두 모델 선언 image_model = None audio_model = None def preprocess_audio(audio_bytes): try: # 1. 바이트 데이터를 오디오 로드 # sr=16000 (학습 데이터셋과 동일해야 함) audio_file = io.BytesIO(audio_bytes) y, sr = librosa.load(audio_file, sr=16000) # 2. MFCC 추출 (n_mfcc=40 학습 설정과 동일해야 함) # 기본 출력 shape: (n_mfcc, time) -> 예: (40, 92) mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=40) # 3. 길이(Time) 고정: 모델이 100을 요구함 target_length = 100 current_length = mfcc.shape[1] if current_length < target_length: # 길이가 짧으면 뒤를 0으로 채움 (Padding) pad_width = target_length - current_length # ((0,0), (0, pad_width)) -> 앞 차원은 그대로, 뒤 차원(Time)만 패딩 mfcc = np.pad(mfcc, ((0, 0), (0, pad_width)), mode='constant') else: # 길이가 길면 100까지만 자름 (Truncating) mfcc = mfcc[:, :target_length] # 4. 차원 변환 (Transpose) # 현재 (40, 100) -> 모델 요구 (100, 40) mfcc = mfcc.T # 5. 배치 차원 추가 # (100, 40) -> (1, 100, 40) mfcc = np.expand_dims(mfcc, axis=0) return mfcc except Exception as e: print(f"전처리 오류: {e}") return None # ========================================== # --- 이미지 전처리 함수 (얼굴 추출 기능 추가) --- def preprocess_image(image_bytes): try: # 1. 바이트 -> Numpy 배열로 변환 (OpenCV 사용을 위해) nparr = np.frombuffer(image_bytes, np.uint8) image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) # 2. 얼굴 탐지 (학습 때와 동일한 로직) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.3, 5) # 3. 얼굴이 있으면 잘라냄(Crop), 없으면 전체 이미지 사용 if len(faces) > 0: # 가장 큰 얼굴 하나만 가져오기 (보통 첫 번째나, 면적 계산 필요하지만 편의상 첫번째) (x, y, w, h) = faces[0] roi = image[y:y+h, x:x+w] # 얼굴 영역 Crop # RGB 변환 (OpenCV는 BGR임) roi = cv2.cvtColor(roi, cv2.COLOR_BGR2RGB) # PIL 이미지로 변환 pil_image = Image.fromarray(roi) print("✅ 얼굴 검출 성공: 얼굴 영역을 모델에 입력합니다.") else: # 얼굴을 못 찾았으면 어쩔 수 없이 전체 이미지를 씀 (RGB 변환) print("⚠️ 얼굴 검출 실패: 전체 이미지를 사용합니다.") pil_image = Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) # 4. 크기 조정 (299x299) pil_image = pil_image.resize(TARGET_IMG_SIZE) # (299, 299) # 5. 정규화 및 차원 추가 img_array = np.array(pil_image) img_array = img_array / 255.0 img_array = np.expand_dims(img_array, axis=0) return img_array except Exception as e: print(f"이미지 전처리 오류: {e}") return None # ========================================== @asynccontextmanager async def lifespan(app: FastAPI): # 전역 변수를 가져옵니다 global image_model, audio_model print("\n========== 서버 시작: 모델 로드 프로세스 ==========") # --- [1] 이미지 모델 로드 --- try: print("1. 이미지 모델(model_image.h5) 로드 중...") image_model = tf.keras.models.load_model("models/model_image.h5", compile=False) print(" ✅ 이미지 모델 로드 성공!") except Exception as e: print(f" ❌ 이미지 모델 로드 실패: {e}") # --- [2] 오디오 모델 로드 --- try: print("2. 오디오 모델(model_audio.h5) 로드 중...") audio_model = tf.keras.models.load_model("models/model_audio.h5", compile=False) print(" ✅ 오디오 모델 로드 성공!") except Exception as e: print(f" ❌ 오디오 모델 로드 실패: {e}") print("==================================================\n") yield # 서버 실행 중... # --- [3] 종료 시 리소스 해제 --- image_model = None audio_model = None print("모든 모델 리소스 해제 완료.") app = FastAPI(lifespan=lifespan) @app.get("/") def read_root(): # 현재 로드 상태를 보여주는 메인 페이지 return { "status": "Server is running", "models_status": { "image_model": "Loaded" if image_model else "Not Loaded", "audio_model": "Loaded" if audio_model else "Not Loaded" } } # --------------------------------------------------------- # 추론용 API # --------------------------------------------------------- ...
Python
복사

2. /deepfake/image 엔드포인트 구현

메서드: POST
역할: 사용자로부터 이미지 파일(Base64 인코딩 또는 파일)을 받아 진위 여부를 판별하여 JSON 응답을 반환.
반환 형식: {"is_deepfake": boolean, "confidence": float}
asd
... # --------------------------------------------------------- # 추론용 API # --------------------------------------------------------- ... # --- [모듈 3] 1. /predict/image 엔드포인트 구현 --- @app.post("/predict/image") async def predict_image(file: UploadFile = File(...)): """ 이미지 파일을 받아 딥페이크 여부를 판별합니다. """ if image_model is None: return {"error": "이미지 모델이 로드되지 않았습니다."} try: # 1. 파일 읽기 image_bytes = await file.read() # 2. 전처리 model_input = preprocess_image(image_bytes) if model_input is None: return {"error": "이미지 처리 중 오류가 발생했습니다. 올바른 이미지 파일인지 확인하세요."} # 3. 모델 추론 prediction = image_model.predict(model_input) # 모델의 출력 형태에 따라 인덱싱이 다를 수 있음 (보통 이진 분류는 [0][0]) confidence = float(prediction[0][0]) # 4. 결과 판별 (0.5 기준) # 0.5 이상이면 Fake(딥페이크), 미만이면 Real(진짜) is_deepfake = confidence < 0.5 return { "filename": file.filename, "is_deepfake": is_deepfake, "confidence": f"{confidence:.4f}", "message": "딥페이크 이미지 탐지 완료" } except Exception as e: # 팁: 여기서 shape mismatch 에러가 나면 TARGET_IMG_SIZE를 수정해야 함 return {"error": f"서버 내부 오류: {str(e)}"}
Python
복사
탐지 결과 테스트
REAL 파일
FAKE 파일
"그래프에서는 90점이었는데, 실전에서는 왜 꽝인가? 내가 본 90점은 '우물 안 개구리' 점수였나?"
'진짜 정확도(Real-World Accuracy)'를 측정하고 올리는 방법을 명확히 알려드리겠습니다.

실제 탐지 확률을 올리는 방법 (Solution)

벤치마크 점수가 낮게 나온다면(아마 50~60% 정도 나올 수 있습니다), 다음 단계로 넘어가야 합니다.
① 데이터 증강(Augmentation) 강화 (가장 시급)
지금은 회전 정도만 하고 있지만, **"실전 압축"**을 가르쳐야 합니다.
ImageDataGenerator에 다음 옵션을 추가해서 재학습하세요.
Brightness/Contrast: 조명이 밝거나 어두운 사진 대응.
Noise: 화질이 나쁜 사진 대응.
Blur: 흔들린 사진 대응.
② 데이터셋 교체 (근본적 해결)
지금 쓰시는 30~50장 데이터는 "기능 구현 검증용"입니다. 상용 수준의 탐지를 원하신다면 데이터 양을 늘려야 합니다.
Kaggle의 'Deepfake Detection Challenge' 데이터셋이나 'FaceForensics++' 데이터셋에서 샘플을 1,000장 정도만 가져와서 학습시켜도 성능이 비약적으로 상승합니다. 요약하자면: 지금은 "기능 구현(서버, 모델 로드, 파이프라인)" 단계는 완벽히 마쳤습니다. 이제 "성능 고도화(데이터 싸움)" 단계로 넘어가신 겁니다. 위 벤치마크 코드로 현재 점수를 확인하는 것이 그 시작입니다.

모듈 4: 전체 시스템 통합 및 연결

1. 최종 연결 구조 (파이프라인)

Frontend (3일차 React): 사용자 입력 (파일 업로드) →
Backend (4일차 NestJS): FastAPI Inference API로 요청 호출 →
AI Inference (6일차 FastAPI): AI 모델 추론 및 결과 반환 → 다시 응답을 백엔드 → 프론트엔드의 화면 렌더링까지 전달 확인

2. 백엔드 API 서버와 추론 서버 연결

2-1. AI Inference Server CORS 설정 (필수)

FastAPI와 NestJS가 서로 다른 포트에서 실행될 경우, API 호출이 가능하도록 CORS (Cross-Origin Resource Sharing) 설정을 추가.
CORS?
CORS는 '교차 출처 리소스 공유'(Cross-Origin Resource Sharing)
브라우저가 임의의 웹 페이지에서 다른 웹 페이지의 자원에 무분별하게 접근하는 것을 막기 위한 정책 XSS(Cross-Site Scripting)와 같은 보안 위협으로부터 웹 페이지를 보호하기 위함
하지만 개발중인 MSA 각 아키텍처간에 서로를 모르기 때문에, 대상 출처(IP, Port)를 허용하도록 명시적으로 작성해주어야 한다.
main.py
... app = FastAPI(lifespan=lifespan) # ========================================== # [추가] CORS 설정 (필수) # NestJS(백엔드)나 React(프론트)에서 요청을 보낼 수 있게 허용 # ========================================== origins = [ "http://localhost:3000", # React (보통 3000번 포트) "http://localhost:4000", # NestJS (보통 4000번 포트? - 사용자 환경에 맞게) "*" # 개발 중에는 모든 곳 허용 (권장) ] app.add_middleware( CORSMiddleware, allow_origins=["*"], # 모든 오리진 허용 allow_credentials=True, allow_methods=["*"], # 모든 메소드 허용 (GET, POST 등) allow_headers=["*"], # 모든 헤더 허용 ) ...
Python
복사

2-2. API Server와 AI Inference Server 연결

NestJSFastAPI
1.
사전 준비 (패키지 설치)
NestJS에서 HTTP 요청을 보내고, 파일 처리를 하기 위해 라이브러리가 필요합니다.
npm install @nestjs/axios axios form-data @types/multer
Python
복사
2.
detection.module.ts 수정
FastAPI로 요청을 보내기 위해 HttpModule을 등록해야 합니다.
import { Module } from '@nestjs/common'; import { DetectionService } from './detection.service'; import { DetectionController } from './detection.controller'; // 1. TypeOrmModule, Detection 임포트 import { Detection } from './entities/detection.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; // 3. HttpModule 임포트 import { HttpModule } from '@nestjs/axios'; @Module({ imports: [ // 2. forFeature()로 이 모듈에서 사용할 Entity를 등록 TypeOrmModule.forFeature([Detection]), // 4. HttpModule 등록 HttpModule, ], controllers: [DetectionController], providers: [DetectionService], }) export class DetectionModule {}
TypeScript
복사
3.
DTO 생성
detection-response.dto.ts
export class DetectionResponseDto { id: number; filename: string; filetype: string; isDeepfake: boolean; confidence: number; createdAt: Date; }
TypeScript
복사
inference-response.dto.ts
export class InferenceResponseDto { filename: string; is_deepfake: boolean; confidence: string; message: string; }
TypeScript
복사
4.
detection.controller.ts 수정
@Body 대신 @UploadedFile()을 사용하여 파일을 받습니다.
FileInterceptor를 사용하여 multipart/form-data를 처리합니다.
import { Controller, Get, Post, Body, Patch, Param, Delete, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { DetectionService } from './detection.service'; import { UpdateDetectionDto } from './dto/update-detection.dto'; import { DetectionResponseDto } from './dto/detection-response.dto'; @Controller('detection') export class DetectionController { constructor(private readonly detectionService: DetectionService) {} @Post() @UseInterceptors(FileInterceptor('file')) async create(@UploadedFile() file: Express.Multer.File): Promise<DetectionResponseDto> { if (!file) { throw new BadRequestException('파일이 업로드되지 않았습니다.'); } return this.detectionService.detectAndSave(file); } @Get() findAll(): Promise<DetectionResponseDto[]> { return this.detectionService.findAll(); } @Get(':id') findOne(@Param('id') id: string) { return this.detectionService.findOne(+id); } @Patch(':id') update(@Param('id') id: string, @Body() updateDetectionDto: UpdateDetectionDto) { return this.detectionService.update(+id, updateDetectionDto); } @Delete(':id') remove(@Param('id') id: string) { return this.detectionService.remove(+id); } }
TypeScript
복사
5.
detection.service.ts 수정 (가장 중요)
여기가 비즈니스 로직의 핵심입니다. 흐름은 다음과 같습니다.
a.
들어온 파일이 이미지인지 오디오인지 구분합니다.
b.
FormData를 만들어서 FastAPI로 전송합니다.
c.
FastAPI의 응답(isDeepfake, confidence)을 받습니다.
d.
DB에 메타데이터 + 결과를 저장합니다.
import { Injectable, NotFoundException, InternalServerErrorException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Detection } from './entities/detection.entity'; import { HttpService } from '@nestjs/axios'; import { lastValueFrom } from 'rxjs'; import FormData from 'form-data'; import { UpdateDetectionDto } from './dto/update-detection.dto'; import { DetectionResponseDto } from './dto/detection-response.dto'; import { InferenceResponseDto } from './dto/inference-response.dto'; @Injectable() export class DetectionService { constructor( @InjectRepository(Detection) private detectionRepository: Repository<Detection>, private readonly httpService: HttpService, ) {} // 탐지(파일 업로드 -> FastAPI 추론 -> 결과 DB 저장) async detectAndSave(file: Express.Multer.File): Promise<DetectionResponseDto> { let inferenceServerApiUrl = ''; const mimeType = file.mimetype; // 1. 파일 타입에 따라 FastAPI 엔드포인트 결정 if (mimeType.startsWith('image/')) { inferenceServerApiUrl = 'http://localhost:8000/predict/image'; } else if (mimeType.startsWith('audio/') || mimeType === 'application/octet-stream') { inferenceServerApiUrl = 'http://localhost:8000/predict/audio'; } else { throw new Error('지원하지 않는 파일 형식입니다. (이미지 또는 오디오만 가능)'); } // 2. FastAPI로 보낼 FormData 생성 let apiResult: InferenceResponseDto | null = null; const formData = new FormData(); formData.append('file', file.buffer, file.originalname); try { // 3. Inference API 서버 호출(Axios 사용) const response = await lastValueFrom( this.httpService.post<InferenceResponseDto>( inferenceServerApiUrl, formData, { headers: formData.getHeaders(), } ), ); apiResult = response.data; } catch (error) { console.error('FastAPI Connection Error:', error.message); throw new InternalServerErrorException('AI 서버와 통신 중 오류가 발생했습니다.'); } // 4. DB에 저장할 엔티티 생성(DTO-Entity 매핑) const newDetection = this.detectionRepository.create({ filename: file.originalname, filetype: file.mimetype, isDeepfake: apiResult.is_deepfake ?? false, confidence: Number(apiResult?.confidence ?? 0.0) }); // 5. DB 저장 및 결과 DTO 반환 const savedEntity = await this.detectionRepository.save(newDetection); const detectionResponseDto = Object.assign(new DetectionResponseDto(), savedEntity); return detectionResponseDto; } async findAll(): Promise<DetectionResponseDto[]> { const detectionResponseDtos = await this.detectionRepository.find({ order: { createdAt: 'DESC' } }); return detectionResponseDtos.map(detection => Object.assign(new DetectionResponseDto(), detection)); } async findOne(id: number): Promise<Detection> { const detection = await this.detectionRepository.findOneBy({ id }); if (!detection) { throw new NotFoundException(`ID #${id} Not Found`); } return detection; } // Update async update(id: number, updateDetectionDto: UpdateDetectionDto) { // 1. 'id'로 찾아서, 'updateDetectionDto'의 내용으로 DB를 업데이트 (UPDATE 쿼리) // (참고: 'update'는 결과로 업데이트된 Entity를 반환하지 않고, 영향 받은 행 수(Affected Rows) 등을 반환합니다.) const updateResult = await this.detectionRepository.update(id, updateDetectionDto); // 2. (에러 핸들링) 만약 업데이트된 행(affected)0개라면, 해당 ID가 없다는 뜻. if (updateResult.affected === 0) { throw new NotFoundException(`ID #${id}에 해당하는 탐지 내역을 찾을 수 없습니다.`); } // 3. 업데이트된 내용을 다시 조회하여 반환 return this.findOne(id); } // Delete async remove(id: number) { // 1. 'id'로 찾아서 DB에서 삭제 (DELETE 쿼리) const deleteResult = await this.detectionRepository.delete(id); // 2. (에러 핸들링) 만약 삭제된 행(affected)0개라면, 해당 ID가 없다는 뜻. if (deleteResult.affected === 0) { throw new NotFoundException(`ID #${id}에 해당하는 탐지 내역을 찾을 수 없습니다.`); } // 3. 성공 메시지 반환 (또는 삭제된 객체를 반환해도 됨) return { deleted: true, message: `ID #${id} 탐지 내역이 성공적으로 삭제되었습니다.`, }; } }
Python
복사
6.
POSTMAN 요청 테스트(백엔드추론서버)
이미지 및 음성 업로드 테스트
목록 조회 테스트

2-3. 백엔드 NestJS API 서버 CORS 설정 (필수)

NestJS와 React가 서로 다른 포트에서 실행될 경우, API 호출이 가능하도록 CORS (Cross-Origin Resource Sharing) 설정을 추가.
NestJS 서버
main.ts
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; // 1. ValidationPipe 임포트 import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); // 2. Global Pipe로 ValidationPipe 추가 app.useGlobalPipes(new ValidationPipe({ whitelist: true, // DTO에 정의되지 않은 속성은 자동으로 제거 transform: true, // 요청 데이터를 DTO 클래스 인스턴스로 변환 })); // [CORS 설정 추가] app.enableCors({ // React(5173)에서의 요청을 허용합니다. // 배열로 여러 개를 넣을 수도 있고, true로 설정하면 모든 곳에서 허용합니다. origin: ['http://localhost:5173', 'http://localhost:3000'], methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', credentials: true, }); await app.listen(3000); } bootstrap();
Python
복사

2-4. 프론트엔드 페이지와 백엔드 API 서버 연결

ReactNestJS
1.
사전 준비 (패키지 설치)
npm install axios
Python
복사
2.
자식 컴포넌트 수정 (ImageUpload.tsx, AudioUpload.tsx)
"파일이 선택됐어요!" 라고 알려주는 기능(onFileSelect)을 추가
components/ImageUpload.tsx
import React, { useEffect, useState } from 'react'; // [1] 부모에게서 받을 함수 타입 정의 interface Props { onFileSelect: (file: File | null) => void; } function ImageUpload({ onFileSelect }: Props) { // 2. 선택된 파일 이름을 표시하기 위한 State const [fileName, setFileName] = useState<string | null>(null); // 3. 파일이 변경되었을 때 실행될 핸들러 const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (event.target.files && event.target.files.length > 0) { const file = event.target.files[0]; setFileName(file.name); onFileSelect(file); } else { setFileName(null); onFileSelect(null); } }; useEffect(() => { // 5. 마운트(처음 뜰 때) 시점에는 fileName이 null이므로 실행하지 않음 if (fileName) { console.log(`[useEffect] 파일이 변경되었습니다: ${fileName}`); // (추후 4일차) // 이곳에서 '파일이 선택되었으니 API 호출을 준비하라' // 같은 부가 작업을 수행할 수 있습니다. } }, [fileName]); // <-- 6. 의존성 배열에 'fileName'을 지정 return ( <> <p className="text-muted small mb-3"> 탐지할 이미지 파일을 업로드하거나 이미지 URL을 입력하세요. </p> <input type="file" id="imageUploadInput" className="d-none" accept="image/png, image/jpeg, image/gif" onChange={handleFileChange} /> {/* 드래그 앤 드롭 영역 */} <div className="border border-2 border-dashed rounded-3 p-4 text-center bg-light cursor-pointer"> <label htmlFor="imageUploadInput" className="border border-2 border-dashed rounded-3 p-4 text-center bg-light cursor-pointer"> <div className="mx-auto d-flex align-items-center justify-content-center bg-white border rounded-circle text-muted mb-3" style={{ width: '48px', height: '48px', fontSize: '24px' }}> + </div> <p className="text-dark fw-semibold mb-1"> 클릭하여 업로드 또는 드래그 앤 드롭 </p> <p className="text-muted small mt-1 mb-0"> PNG, JPG, GIF (MAX. 10MB) </p> </label> </div> {/* 7. (UX 개선) 선택된 파일 이름 표시 */} {fileName && ( <div className="text-center text-muted small mt-2"> <strong>선택된 파일:</strong> {fileName} </div> )} {/* '또는' 구분선 */} <div className="d-flex align-items-center my-4"> <hr className="flex-grow-1" /> <span className="mx-3 text-muted small">또는</span> <hr className="flex-grow-1" /> </div> {/* 이미지 URL 입력 영역 */} <div> <label htmlFor="imageUrl" className="form-label fw-medium small mb-1"> 이미지 URL </label> <input type="text" id="imageUrl" placeholder="https://example.com/image.jpg" className="form-control" /> </div> </> ); } export default ImageUpload;
TypeScript
복사
components/AudioUpload.tsx
import React, { useEffect, useState } from 'react'; // [1] 부모에게서 받을 함수 타입 정의 interface Props { onFileSelect: (file: File | null) => void; } function AudioUpload({ onFileSelect }: Props) { // 2. 선택된 파일 이름을 표시하기 위한 State const [fileName, setFileName] = useState<string | null>(null); // 3. 파일이 변경되었을 때 실행될 핸들러 const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (event.target.files && event.target.files.length > 0) { const file = event.target.files[0]; setFileName(file.name); onFileSelect(file); } else { setFileName(null); onFileSelect(null); } }; useEffect(() => { // 5. 마운트(처음 뜰 때) 시점에는 fileName이 null이므로 실행하지 않음 if (fileName) { console.log(`[useEffect] 파일이 변경되었습니다: ${fileName}`); // (추후 4일차) // 이곳에서 '파일이 선택되었으니 API 호출을 준비하라' // 같은 부가 작업을 수행할 수 있습니다. } }, [fileName]); // <-- 6. 의존성 배열에 'fileName'을 지정 return ( <> <p className="text-muted small mb-3"> 탐지할 음성 파일을 업로드하거나 음성 URL을 입력하세요. </p> <input type="file" id="audioUploadInput" className="d-none" accept="audio/mp3, audio/wav, audio/ogg" onChange={handleFileChange} /> {/* 드래그 앤 드롭 영역 */} <div className="border border-2 border-dashed rounded-3 p-4 text-center bg-light cursor-pointer"> <label htmlFor="audioUploadInput" className="border border-2 border-dashed rounded-3 p-4 text-center bg-light cursor-pointer"> <div className="mx-auto d-flex align-items-center justify-content-center bg-white border rounded-circle text-muted mb-3" style={{ width: '48px', height: '48px', fontSize: '24px' }}> + </div> <p className="text-dark fw-semibold mb-1"> 클릭하여 업로드 또는 드래그 앤 드롭 </p> <p className="text-muted small mt-1 mb-0"> MP3, WAV, OGG (MAX. 10MB) </p> </label> </div> {/* 7. (UX 개선) 선택된 파일 이름 표시 */} {fileName && ( <div className="text-center text-muted small mt-2"> <strong>선택된 파일:</strong> {fileName} </div> )} {/* '또는' 구분선 */} <div className="d-flex align-items-center my-4"> <hr className="flex-grow-1" /> <span className="mx-3 text-muted small">또는</span> <hr className="flex-grow-1" /> </div> {/* 음성 URL 입력 영역 */} <div> <label htmlFor="audioUrl" className="form-label fw-medium small mb-1"> 음성 URL </label> <input type="text" id="audioUrl" placeholder="https://example.com/audio.mp3" className="form-control" /> </div> </> ); } export default AudioUpload;
TypeScript
복사
3.
부모 페이지 수정 및 Axios 요청부, 결과 어펜드 추가
DeepfakeDetector.tsx
import { useState } from 'react'; import axios from 'axios'; import ImageUpload from '../components/ImageUpload'; import AudioUpload from '../components/AudioUpload'; type TabName = 'image' | 'audio'; // [2] 서버 응답 데이터 타입 정의 (백엔드 DTO와 일치) interface DetectionResult { filename: string; isDeepfake: boolean; confidence: number; } function DeepfakeDetector() { const [activeTab, setActiveTab] = useState<TabName>('image'); // [3] 상태 관리 (파일, 로딩, 결과) const [selectedFile, setSelectedFile] = useState<File | null>(null); const [isLoading, setIsLoading] = useState(false); const [result, setResult] = useState<DetectionResult | null>(null); const apiServerApiUrl = 'http://localhost:3000/detection'; // 탭 변경 시 상태 초기화 const handleTabChange = (tab: TabName) => { setActiveTab(tab); setSelectedFile(null); setResult(null); }; // [4] 탐지 요청 함수 (핵심 로직) const handleDetect = async () => { if (!selectedFile) { alert('파일을 먼저 업로드해주세요.'); return; } setIsLoading(true); // 로딩 시작 setResult(null); // 이전 결과 초기화 try { // FormData 생성 const formData = new FormData(); formData.append('file', selectedFile); // 백엔드(NestJS)로 요청 전송 const response = await axios.post<DetectionResult>( apiServerApiUrl, formData, { headers: { 'Content-Type': 'multipart/form-data' }, } ); // 결과 저장 setResult(response.data); console.log('탐지 결과:', response.data); } catch (error) { console.error('탐지 에러:', error); alert('탐지 중 오류가 발생했습니다. 백엔드 서버가 켜져있는지 확인하세요.'); } finally { setIsLoading(false); } }; return ( <div className="card shadow-sm" style={{ width: '100%', maxWidth: '500px' }}> <div className="card-body p-4 p-md-5"> <h1 className="text-center h3 fw-bold text-dark">딥페이크 탐지기</h1> {/* 탭 메뉴 */} <ul className="nav nav-tabs nav-fill mb-4"> <li className="nav-item"> <button className={`nav-link ${activeTab === 'image' ? 'active' : ''}`} onClick={() => handleTabChange('image')} > 이미지 탐지 </button> </li> <li className="nav-item"> <button className={`nav-link ${activeTab === 'audio' ? 'active' : ''}`} onClick={() => handleTabChange('audio')} > 음성 탐지 </button> </li> </ul> {/* 업로드 컴포넌트 (파일 선택 시 부모의 state를 업데이트함) */} <div> {activeTab === 'image' && <ImageUpload onFileSelect={setSelectedFile} />} {activeTab === 'audio' && <AudioUpload onFileSelect={setSelectedFile} />} </div> {/* 탐지 버튼 */} <button className="btn btn-primary w-100 py-2 fw-bold mt-5" onClick={handleDetect} disabled={isLoading || !selectedFile} // 로딩 중이거나 파일 없으면 비활성화 > {isLoading ? ( <span> <span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> 분석 중... </span> ) : ( '탐지 시작 (Detect)' )} </button> {/* [5] 결과 표시 영역 */} {result && ( <div className={`alert mt-4 ${result.isDeepfake ? 'alert-danger' : 'alert-success'}`} role="alert"> <h4 className="alert-heading fw-bold"> {result.isDeepfake ? '⚠️ 딥페이크 감지됨!' : '✅ 진짜(Real) 파일입니다'} </h4> <hr /> <p className="mb-0"> <strong>확률(Confidence):</strong> {(result.confidence * 100).toFixed(2)}% </p> <p className="mb-0 small text-muted"> 파일명: {result.filename} </p> </div> )} </div> </div> ); } export default DeepfakeDetector;
TypeScript
복사
4.
페이지 실행 테스트

3. 테스트 및 디버깅

React 앱에서 음성 및 이미지 파일을 업로드하고, FastAPI 서버 로그에서 추론이 정상적으로 이루어지는지 실시간 확인 및 최종 디버깅.
Client Server Inference 로그 확인
Client
Backend
Inference

모듈 5: Docker를 통한 인프라 환경 설정과 배포

"프론트엔드(Nginx) + 백엔드 + AI + DB" 총 4개의 컨테이너를 docker-compose 하나로 묶어서 실행하는 가장 심플하고 표준적인 방법을 정리

1. 프론트엔드 Docker 이미지 빌드 및 웹서버 구성

프론트엔드+Nginx 웹서버
1.
root/Dockerfile 생성
# 1. 빌드 단계 (Builder) # 로컬 버전과 맞춰서 20 버전 사용 FROM node:20-alpine as builder WORKDIR /app COPY package.json . # 여기서 설치되는 node_modules는 리눅스용입니다. RUN npm install # .dockerignore가 있다면 로컬의 node_modules는 복사되지 않습니다. COPY . . RUN npm run build # 2. 실행 단계 (Nginx) FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]
TypeScript
복사
2.
root/nginx.conf 생성
server { listen 80; location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; } }
TypeScript
복사
3.
root/.dockerignore 생성
node_modules dist .git .env.local
TypeScript
복사
4.
docker build
docker build -t detect-frontend ./
TypeScript
복사
5.
컨테이너 설정
6.
컨테이너 실행 모습 (DB 연결필요)

2. 백엔드 Docker 이미지 빌드

백엔드 WAS
1.
root/Dockerfile 생성
# 1. 빌드 단계 (Builder) # 로컬 버전과 맞춰서 20 버전 사용 FROM node:20-alpine as builder WORKDIR /app # 패키지 파일만 먼저 복사 (캐시 효율을 위해) COPY package*.json ./ # 의존성 설치 RUN npm install # 소스 코드 전체 복사 COPY . . # 빌드 RUN npm run build # 포트 개방 EXPOSE 3000 # 실행 CMD ["node", "dist/main"]
TypeScript
복사
2.
root/.dockerignore 생성
node_modules dist .git .env.local
TypeScript
복사
3.
docker build
docker build -t detect-backend ./
TypeScript
복사
4.
컨테이너 설정
5.
컨테이너 실행 모습

3. AI 추론 서버 Docker 이미지 빌드

Inference Server + Model
1.
패키지 리스트 갱신 pip freeze > requirements.txt
2.
root/Dockerfile 생성
FROM python:3.10-slim WORKDIR /app # 1. 의존성 설치 # (headless 버전을 쓰므로 시스템 라이브러리 설치 단계 삭제됨) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 2. 소스 코드 복사 COPY . . # 3. 호환성 환경변수 ENV TF_USE_LEGACY_KERAS=1 EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
TypeScript
복사
3.
root/.dockerignore 생성
venv __pycache__ .git .idea
TypeScript
복사
4.
docker build
docker build --no-cache -t detect-inference ./
TypeScript
복사
5.
컨테이너 설정
6.
컨테이너 실행 모습

4. Docker-compose

현재 Docker 각 이미지들은 개별 컨테이너로 실행되고 있다.
또한 데이터베이스도 별도 컨테이너가 필요하다(mysql)
Docker-compose를 통한 애플리케이션 컨테이너 통합
1.
ideaProjects/docker-compose.yml 생성(3개 총 포함 루트폴더)
version: '3.8' services: # 1. 데이터베이스 (MySQL) db: image: mysql:8.0 container_name: deepfake-db restart: always environment: MYSQL_ROOT_PASSWORD: 1234 MYSQL_DATABASE: deepfake_db ports: - "3306:3306" volumes: - db_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] timeout: 20s retries: 10 # 2. AI 서버 (FastAPI) ai-server: build: ./detect-inference container_name: deepfake-ai ports: - "8000:8000" environment: - TF_USE_LEGACY_KERAS=1 # 3. 백엔드 (NestJS) backend: build: ./detect-backend container_name: deepfake-backend ports: - "3000:3000" environment: # 도커 내부 통신용 주소 (localhost 대신 서비스명 사용) DB_HOST: db DB_PORT: 3306 DB_USERNAME: root DB_PASSWORD: 1234 DB_DATABASE: deepfake_db # AI 서버 주소도 서비스명으로 변경 AI_API_URL: http://ai-server:8000 depends_on: db: condition: service_healthy ai-server: condition: service_started # 4. 프론트엔드 (React + Nginx) frontend: build: ./detect-frontend container_name: deepfake-frontend ports: - "80:80" depends_on: - backend volumes: db_data:
TypeScript
복사
2.
컴포즈 실행
docker-compose up --build
TypeScript
복사
3.
통합 컨테이너 실행 화면
4.
서비스 실행 화면(Nginx 80포트)
5.
추가 작업 필요 부분
a.
localhost 표기 부분을 환경 변수로 변경 필요
수정 필요 부분 상세
b.
각 기능 테스트로 통신 상태 점검
c.
외부 IP 인바운드 규칙 설정 등 외부 네트워크 환경 접근 설정 등
d.
자동 통합/자동 배포(CI/CD) 파이프라인 구축
최종 목표: 6일간 개발한 전체 딥페이크 탐지 앱을 시연하고, 프로젝트 과정(데이터 수집, 전처리, 모델 훈련, 서버 통합)에 대해 질의응답을 진행하며 마무리.
Search