정화 코딩

[AI] 졸업 프로젝트 PreView: AI 서버 구축, RAG 초기 세팅 후 백엔드 API와 연결 본문

AI

[AI] 졸업 프로젝트 PreView: AI 서버 구축, RAG 초기 세팅 후 백엔드 API와 연결

jungh150c 2025. 5. 19. 18:48

프로젝트 소개

우리 팀 찹쌀떡은 졸업 프로젝트로 "취업 준비자를 위해 gpt-4o를 이용하여 자기소개서를 분석하고 RAG 기반으로 예상 질문을 제공하는 면접 시뮬레이션 서비스" PreView를 만들고자 한다. 개발을 진행함에 따라 AI 처리 전용 서버의 필요성을 느껴, 별도의 AI 서버를 구축하고 RAG 초기 세팅 및 백엔드 API 연동까지 진행하게 되었다. 이 글에서 그 전체 과정을 정리해보고자 한다.

https://github.com/Chapssal-tteok

 

Chapssal-tteok

Chapssal-tteok has 4 repositories available. Follow their code on GitHub.

github.com

 


AI 서버 레포지토리 생성 및 초기 세팅

https://github.com/Chapssal-tteok/AI

 

GitHub - Chapssal-tteok/AI

Contribute to Chapssal-tteok/AI development by creating an account on GitHub.

github.com

AI 서버 레포지토리를 생성하였다.

AI/
├── app/                             # 애플리케이션 주요 모듈
│   ├── api/                         # FastAPI 라우팅 관련 파일
│   │   ├── interview/               # 면접 관련 API 라우터
│   │   │   └── route.py
│   │   ├── chroma.py                # ChromaDB 관련 API 라우터
│   │   ├── perplexity.py            # Perplexity 관련 API 라우터
│   │   └── __init__.py
│   ├── core/                        # 핵심 설정 및 유틸
│   │   ├── config.py                # 환경 변수 로딩 등 설정 파일
│   │   ├── init_chroma.py           # ChromaDB 초기화용 스크립트
│   │   └── vector_utils.py          # 벡터 처리 유틸 함수들
│   ├── models/                      # 검색 관련 모델 정의
│   │   └── search_model.py
│   ├── prompts/                     # 프롬프트 모음
│   │   ├── analyze_answer_prompts.py
│   │   ├── follow_up_prompts.py
│   │   ├── interview_qas_prompts.py
│   │   └── resume_analyze_prompts.py
│   ├── services/                    # 실제 기능 처리 로직 (서비스 계층)
│   │   ├── chroma_service.py        # 벡터 검색 관련 로직
│   │   ├── gpt_service.py           # GPT-4o API 호출 관련
│   │   └── perplexity_service.py    # Perplexity 호출 및 처리
│   └── __init__.py
│
├── db/                              # ChromaDB 데이터 저장 폴더
│
├── .env                             # 환경 변수 파일 (.env)
├── dataset_question.csv             # 크롤링을 통해 얻은 기업, 직무 별 면접 질문 데이터
├── main.py                          # FastAPI 앱 진입점
├── README.md                        # 프로젝트 설명서
└── requirements.txt                 # Python 의존성 명세

전체 디렉토리 구조는 위와 같다.

# requirements.txt

fastapi==0.110.0
uvicorn==0.29.0
openai==1.14.3
requests==2.31.0
python-dotenv==1.0.1
sentence-transformers==2.2.2
huggingface_hub==0.16.4
langchain==0.1.14
chromadb==0.4.24
onnxruntime==1.19.2

FastAPI를 기반으로 서버를 구축했고, uvicorn을 통해 비동기 서버를 실행한다. OpenAI의 GPT-4o 모델을 호출하기 위해 openai 패키지를 사용했고, 외부 API 호출에는 requests, 환경변수 관리를 위해 python-dotenv를 사용했다.

RAG 기반 검색 기능 구현을 위해 문장 임베딩에는 sentence-transformers, 벡터 저장소로는 chromadb, 그리고 전체 RAG 구성 흐름을 다루기 위해 langchain을 함께 사용했다.

이 외에도 HuggingFace 모델 허브 연동을 위한 huggingface_hub, ONNX 모델 실행을 위한 onnxruntime 등이 포함되어 있다.

 


AI 서버의 주요 코드 작성

# main.py

from fastapi import FastAPI
from app.api import perplexity, chroma
from app.api.interview import route as interview_route

app = FastAPI()

app.include_router(perplexity.router, prefix="/perplexity")
app.include_router(chroma.router, prefix="/chroma")
app.include_router(interview_route.router, prefix="/interview")

서버의 진입점인 main.py에서는 FastAPI 인스턴스를 생성하고, 세 개의 주요 기능 모듈을 라우터로 등록한다.

perplexity는 Perplexity API와 연동되는 라우터이고, chroma는 벡터 검색을 위한 ChromaDB 관련 라우터입니다. (이 둘은 테스트용 API이다.)

interview_route는 실제로 사용하는 API들이 모여 있는 라우터로, 자기소개서 분석, 예상 질문 생성 등과 관련된 API를 담당한다.

# .env

OPENAI_API_KEY=my-openai-api-key
PPLX_API_KEY=my-perplexity-api-key

.env 파일에는 외부 API 호출에 필요한 인증 키를 환경 변수로 정의한다.

# app/api/interview/route.py

from fastapi import APIRouter, Body
from pydantic import BaseModel
from typing import List, Dict
from app.services.chroma_service import search_similar_questions
from app.services.gpt_service import get_chat_response
from app.services.perplexity_service import search_perplexity_summary
from app.prompts.resume_analyze_prompts import generate_resume_analysis_prompt
from app.prompts.analyze_answer_prompts import analyze_answer_prompt
from app.prompts.follow_up_prompts import generate_follow_up_prompt
from app.prompts.interview_qas_prompts import generate_interview_qas_prompt

router = APIRouter()

class ResumeRequest(BaseModel):
    question: str
    resume: str
    company: str
    position: str

class AnswerAnalysisRequest(BaseModel):
    question: str
    answer: str
    resume: str

class FollowUpRequest(BaseModel):
    question: str
    answer: str

class InterviewQasRequest(BaseModel):
    company: str
    position: str
    resumeContent: str

@router.post(
    "/analyze-resume",
    summary="자기소개서 분석",
    description="하나의 자기소개서 문항과 답변, 그리고 지원 정보(기업, 직무)를 바탕으로 피드백을 제공합니다.",
    response_model=Dict[str, List[str]],
    responses={
        200: {
            "description": "분석된 피드백 목록 반환",
            "content": {
                "application/json": {
                    "example": {
                        "feedback": [
                            "**질문 충실도**: 질문의 핵심 의도에 맞춰 구체적인 동기를 잘 설명해주셨어요.",
                            "**논리적인 흐름**: 서론-본론-결론 구조가 깔끔하게 드러나서 이해하기 좋았어요.",
                            "**구체성**: 특정 프로젝트와 수치가 포함되어 있어서 설득력이 높아요.",
                            "**기업과 직무 연관성**: 카카오가 강조하는 사용자 중심 가치와 경험이 잘 연결되었어요.",
                            "**종합 피드백**: 전반적으로 완성도 높은 답변입니다. 마무리에서 입사 후 포부를 좀 더 분명히 드러내면 좋을 것 같아요."
                        ]
                    }
                }
            }
        }
    }
)
async def analyze_resume(req: ResumeRequest = Body(..., example={
    "question": "지원 동기와 입사 후 포부를 작성해주세요.",
    "resume": "저는 카카오의 사용자 중심 철학에 깊이 공감하여 지원하게 되었습니다...",
    "company": "카카오",
    "position": "백엔드 개발자"
})):
    # 1. 기업 정보 요약 검색
    company_summary = search_perplexity_summary(req.company)

    # 2. 프롬프트 생성
    prompt = generate_resume_analysis_prompt(
        question=req.question,
        resume=req.resume,
        company=req.company,
        position=req.position,
        company_summary=company_summary
    )

    # 3. GPT 응답
    response = get_chat_response(prompt, model="gpt-4o", mode="text")

    if not response or not isinstance(response, str):
        return {"feedback": ["GPT 분석에 실패했습니다."]}

    # 4. 줄 단위 피드백 파싱
    feedback = [line.strip() for line in response.split("\n") if line.strip()]
    return {"feedback": feedback}

@router.post(
    "/analyze-answer",
    summary="면접 답변 분석",
    description="면접 질문과 답변, 자기소개서를 기반으로 답변의 강점, 약점, 개선 포인트 등을 분석합니다.",
    response_model=Dict[str, str],
    responses={
        200: {
            "description": "답변 분석 결과 반환",
            "content": {
                "application/json": {
                    "example": {
                        "analysis": "1. 강점: 질문 의도를 잘 파악하고 경험을 구체적으로 서술함..."
                    }
                }
            }
        }
    }
)
async def analyze_answer(req: AnswerAnalysisRequest = Body(..., example={
    "question": "어려운 상황에서 갈등을 해결한 경험이 있나요?",
    "answer": "저는 동아리 프로젝트에서 일정이 지연된 팀원과 갈등을 겪은 적 있습니다...",
    "resume": "저는 다양한 협업 프로젝트를 통해 갈등 조정 능력을 키웠습니다..."
})):
    prompt = analyze_answer_prompt(req.question, req.answer, req.resume)
    response = get_chat_response(prompt, model="gpt-4o", mode="text")

    if not response or not isinstance(response, str):
        return {"message": "답변 분석 실패"}

    return {"analysis": response}

@router.post(
    "/follow-up",
    summary="추가 면접 질문 생성",
    description="지원자의 답변을 바탕으로 더 깊이 있는 추가 면접 질문을 생성합니다.",
    response_model=Dict[str, List[str]],
    responses={
        200: {
            "description": "추가 질문 목록 반환",
            "content": {
                "application/json": {
                    "example": {
                        "followUps": ["당시 팀원과의 갈등을 해결한 과정에서 가장 어려웠던 점은 무엇인가요?"]
                    }
                }
            }
        }
    }
)
async def generate_follow_up(req: FollowUpRequest = Body(..., example={
    "question": "갈등을 해결한 경험이 있나요?",
    "answer": "네, 저는 프로젝트에서..."
})):
    prompt = generate_follow_up_prompt(req.question, req.answer)
    response = get_chat_response(prompt, model="gpt-4o", mode="text")

    if not response or not isinstance(response, str):
        return {"message": "추가 질문 생성 실패"}

    follow_ups = [line for line in response.split("\n") if line.strip()]
    return {"followUps": follow_ups}

@router.post(
    "/generate-qas",
    summary="면접 질문 생성",
    description="기업과 직무 정보, 자기소개서를 기반으로 예상 면접 질문을 생성합니다.",
    response_model=Dict[str, List[str]],
    responses={
        200: {
            "description": "생성된 면접 질문 목록 반환",
            "content": {
                "application/json": {
                    "example": {
                        "questions": [
                            "[네이버] 백엔드 개발자로서 최근 프로젝트 중 가장 도전적이었던 경험은 무엇인가요?",
                            "자신의 기술 스택 중 네이버에서 가장 잘 활용할 수 있는 역량은 무엇이라고 생각하나요?"
                        ]
                    }
                }
            }
        }
    }
)
async def generate_interview_questions(req: InterviewQasRequest = Body(..., example={
    "company": "네이버",
    "position": "백엔드 개발자",
    "resumeContent": "저는 대규모 트래픽 처리를 위한 백엔드 시스템 설계를 경험했습니다..."
})):
    # 1. 기업 + 직무로 Perplexity 요약 검색
    search_query = f"{req.company} {req.position}"
    pplx_summary = search_perplexity_summary(search_query)

    # 2. Chroma에서 기업, 직무 관련 질문 가져오기
    company_questions = search_similar_questions(req.company)
    position_questions = search_similar_questions(req.position)

    company_q_text = "\n".join([q["content"] for q in company_questions])
    position_q_text = "\n".join([q["content"] for q in position_questions])

    chroma_examples = f"[기업: {req.company}] 관련 질문들:\n{company_q_text}\n\n[직무: {req.position}] 관련 질문들:\n{position_q_text}"

    # 3. 요약 결과와 자기소개서 내용을 합쳐 프롬프트 생성
    prompt = generate_interview_qas_prompt(pplx_summary, req.resumeContent, chroma_examples)
    
    # 4. GPT로 질문 생성
    response = get_chat_response(prompt, model="gpt-4o", mode="text")

    if not response or not isinstance(response, str):
        return {"message": "질문 생성 실패"}

    questions = [line for line in response.split("\n") if line.strip()]
    return {"questions": questions}

route.py 파일은 FastAPI 기반의 라우터로, 면접 시뮬레이션 서비스에서 사용하는 핵심 AI 기능 API들을 정의하고 있다. 주요 기능은 총 네 가지이다.

첫 번째는 /analyze-resume API로, 자기소개서 문항과 답변, 그리고 지원 기업·직무 정보를 바탕으로 GPT-4o를 통해 피드백을 생성한다. 이때 Perplexity API로 기업 정보를 검색해 프롬프트에 반영한다.

두 번째는 /analyze-answer API로, 면접 질문과 사용자의 답변, 자기소개서 내용을 기반으로 답변의 강점과 약점을 분석한다. 분석 역시 GPT-4o를 통해 수행된다.

세 번째는 /follow-up API로, 지원자의 답변을 바탕으로 더 깊이 있는 추가 면접 질문을 생성한다. 이는 인터뷰의 깊이를 확장하기 위한 기능이다.

마지막으로 /generate-qas API는 기업·직무·자기소개서를 기반으로 예상 면접 질문을 생성하는 기능이다. 이 기능은 RAG 구조를 따르며, Perplexity로 기업 요약 정보를 얻고, ChromaDB에서 유사 질문 예시를 가져와 프롬프트에 활용한 뒤 GPT-4o가 최종 질문을 생성한다.

# app/services/gpt_service.py

import os
import requests
from dotenv import load_dotenv
from typing import Optional

# .env 로드
load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

def get_chat_response(prompt: str, model: str = "gpt-4o", mode: str = "text") -> Optional[str]:
    url = "https://api.openai.com/v1/chat/completions"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {OPENAI_API_KEY}"
    }

    messages = [
        {
            "role": "system",
            "content": (
                "당신은 자기소개서를 분석해 피드백을 제공하고, "
                "면접 질문의 답변을 분석하며, 추가 질문을 제공하는 취업 컨설팅 전문가입니다. "
                "구체적이고 실질적인 피드백을 제공해주세요."
            )
        },
        {
            "role": "user",
            "content": prompt
        }
    ]

    payload = {
        "model": model,
        "messages": messages,
        "max_tokens": 1000,
        "temperature": 0.7,
        "top_p": 0.9,
        "stream": False
    }

    try:
        response = requests.post(url, headers=headers, json=payload)
        response.raise_for_status()

        data = response.json()
        return data["choices"][0]["message"]["content"]

    except Exception as e:
        print("❌ GPT API 호출 오류:", e)
        return None

gpt_service.pyGPT-4o를 호출하는 함수가 정의된 파일이다. 프롬프트와 함께 OpenAI의 Chat Completions API에 요청을 보내고, 응답 결과를 문자열 형태로 반환한다. 

# app/services/perplexity_service.py

import os
import requests
from dotenv import load_dotenv

# .env 로드
load_dotenv()

PPLX_API_KEY = os.getenv("PPLX_API_KEY")

def search_perplexity_summary(query: str) -> str:
    """
    Perplexity API를 이용하여 기업과 직무 관련 요약 정보를 가져옵니다.
    """
    url = "https://api.perplexity.ai/chat/completions"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {PPLX_API_KEY}",
    }

    messages = [
        {
            "role": "system",
            "content": (
                "당신은 전문적인 리서치 어시스턴트입니다. "
                "사용자의 질의에 대해 핵심적인 기업 및 직무 개요를 짧고 간결하게 정리해 주세요."
            ),
        },
        {
            "role": "user",
            "content": f"{query}에 대한 개요를 알려줘. 기업 개요와 직무의 특징을 중심으로 요약해줘.",
        },
    ]

    payload = {
        "model": "sonar",
        "messages": messages,
        "max_tokens": 700,
        "temperature": 0.7,
        "top_p": 0.9,
        "stream": False
    }

    try:
        response = requests.post(url, headers=headers, json=payload)
        response.raise_for_status()
        data = response.json()
        return data["choices"][0]["message"]["content"]

    except Exception as e:
        print(f"❌ Perplexity API 호출 실패: {e}")
        return "Perplexity 검색 실패"

perplexity_service.py 파일은 Perplexity API를 통해 특정 기업과 직무에 대한 요약 정보를 가져오는 기능을 담당한다.

# app/services/chroma_service.py

from app.core.vector_utils import get_chroma_db

def search_similar_questions(query: str):
    db = get_chroma_db()
    results = db.similarity_search(query, k=3)
    return [
        {
            "content": doc.page_content,
            "metadata": doc.metadata
        }
        for doc in results
    ]

chroma_service.py 파일은 ChromaDB에서 벡터 검색을 통해 입력 쿼리와 유사한 면접 질문들을 찾아주는 기능을 담당한다. get_chroma_db()로 데이터베이스를 로딩한 후, similarity_search()를 통해 가장 관련성 높은 질문 3개를 반환한다. 검색된 문서의 본문과 메타데이터를 함께 제공한다.

 


AI 서버의 ChromaDB 관련 코드 작성

# app/core/init_chroma.py

import os
import pandas as pd
import shutil
from sentence_transformers import SentenceTransformer
from langchain_community.vectorstores import Chroma
from langchain.docstore.document import Document

class LangChainSentenceTransformer:
    def __init__(self, model_name):
        self.model = SentenceTransformer(model_name)

    def embed_documents(self, texts):
        return self.model.encode(texts, show_progress_bar=True).tolist()

    def embed_query(self, text):
        return self.model.encode([text])[0]

def init_db(csv_filename="dataset_question.csv", persist_directory="./db"):
    if not os.path.exists(csv_filename):
        raise FileNotFoundError(f"{csv_filename} 파일이 없습니다.")
    
    df = pd.read_csv(csv_filename)

    documents = [
        Document(
            page_content=f"{row['질문']} [기업명: {row['기업명']}, 경력: {row['경력']}, 직무: {row['직무']}]",
            metadata={"기업명": row["기업명"], "경력": row["경력"], "직무": row["직무"]}
        )
        for _, row in df.iterrows()
        if pd.notna(row["질문"]) and row["질문"].strip() != ""
    ]

    embedder = LangChainSentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

    if os.path.exists(persist_directory):
        shutil.rmtree(persist_directory)
    
    db = Chroma.from_documents(documents, embedder, persist_directory=persist_directory)
    print(f"✅ DB 초기화 완료. 총 문서 수: {len(documents)}")

# CLI 실행용
if __name__ == "__main__":
    init_db()

init_chroma.pyCSV 파일 기반으로 ChromaDB를 초기화하는 스크립트이다.
dataset_question.csv 파일에서 질문 데이터를 불러와 문서 객체로 변환하고, 지정된 임베딩 모델(MiniLM)을 통해 벡터화한 뒤 ./db 디렉토리에 저장한다.

# app/core/vector_utils.py

from sentence_transformers import SentenceTransformer
from langchain_community.vectorstores import Chroma
from app.core.config import settings

class LangChainSentenceTransformer:
    def __init__(self, model_name):
        self.model = SentenceTransformer(model_name)

    def embed_documents(self, texts):
        return self.model.encode(texts, show_progress_bar=False).tolist()

    def embed_query(self, text):
        return self.model.encode([text])[0].tolist()

def get_chroma_db():
    embedding_function = LangChainSentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
    return Chroma(persist_directory="./db", embedding_function=embedding_function)

vector_utils.py 파일은 ChromaDB와 연동해 사용할 벡터 검색용 임베딩 모델과 DB 인스턴스를 구성하는 유틸이다.
LangChainSentenceTransformer는 sentence-transformers 기반 모델을 래핑하여 LangChain에서 사용할 수 있도록 하고, get_chroma_db() 함수는 해당 모델을 이용해 저장된 벡터 DB(./db)를 로드해 반환한다.
API 서버에서 유사 질문 검색 기능에 이 DB를 사용하게 된다.

 


AI 서버를 위한 인스턴스 생성

이 글에서 백엔드 서버용 인스턴스를 생성했던 것과 동일한 방식으로 AI 서버용 EC2 인스턴스도 생성하였다.

 

EC2 인스턴스의 보안 그룹에서는 FastAPI 서버 실행을 위해 8000번 포트를 열고, SSH 접속을 위해 22번 포트도 허용했다.

ssh -i [로컬에 있는 .pem 키 경로] ec2-user@[EC2 인스턴스의 퍼블릭 IPv4 DNS]

위의 명령어를 통해 AI 서버용 EC2 인스턴스에 접속할 수 있다.

 


AI 서버 배포 과정

1. 깃, 파이썬 설치

sudo yum update -y
sudo yum install git -y
sudo yum install -y python3

2. 서버에 프로젝트 클론

git clone https://github.com/Chapssal-tteok/AI.git
cd AI

3. 가상 환경 생성 및 활성화

python3 -m venv venv
source venv/bin/activate

4. 패키지 설치

mkdir -p $HOME/tmp
TMPDIR=$HOME/tmp pip install -r requirements.txt
# 일반적인 경우, pip install -r requirements.txt

일반적으로는 pip install 명령어를 사용하지만, EC2 환경에서 /tmp 디렉토리 용량 부족 문제가 발생할 수 있기 때문에 TMPDIR를 명시해주는 방식으로 설치해주었다. 

 

5. .env 파일 생성

OPENAI_API_KEY=my-openai-api-key
PPLX_API_KEY=my-perplexity-api-key

로컬에서 사용하던 .env 파일을 그대로 복사하여 생성하면 된다.

 

6. ChromaDB 초기화

export PYTHONPATH=.
python app/core/init_chroma.py

이 때, 질문 데이터를 벡터로 임베딩하고 ChromaDB에 저장하는 과정에서 비교적 많은 메모리를 사용한다.특히 EC2 t2.micro, t3.micro 같은 소형 인스턴스에서는 메모리 부족(OOM) 오류가 발생할 수 있다.

 

나도 같은 문제를 직면하였다.

이럴 경우 스왑 메모리(Swap Memory)를 추가하면 문제를 해결할 수 있다.

# 1. 2GB 스왑 파일 생성
sudo fallocate -l 2G /swapfile

# 2. 권한 설정 (보안상 600으로 제한)
sudo chmod 600 /swapfile

# 3. 스왑 영역으로 초기화
sudo mkswap /swapfile

# 4. 스왑 활성화
sudo swapon /swapfile

# 5. 스왑 적용 확인
free -h

스왑 메모리 추가를 통해 성공적으로 ChromaDB 초기화를 마친 것을 확인할 수 있다.

 

7. 백그라운드로 서버 실행

nohup uvicorn main:app --host 0.0.0.0 --port 8000 &

8. 서버 재시작이 필요한 경우

# 1. 실행 중인 프로세스 확인
ps aux | grep uvicorn

# 2. 서버 프로세스 종료
kill 12345

# 3. 최신 코드로 업데이트
git pull

# 4. 가상환경 활성화
source venv/bin/activate

# 5. 서버 재실행
nohup uvicorn main:app --host 0.0.0.0 --port 8000 &

 


백엔드 API에 배포한 AI 서버 연결

PreView 프로젝트에서는 면접 시뮬레이션 기능의 일부를 Python 기반 AI 서버에서 처리하고, 이를 Spring 백엔드에서 HTTP로 호출하여 사용하는 구조로 설계되어 있다. FastAPI 서버는 GPT-4o와 RAG 기반 질문 생성을 담당하고, Spring 서버는 이를 사용자 서비스 흐름에 통합한다.

 

1. WebClient를 활용한 HTTP 통신 설정

package com.chapssal_tteok.preview.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;

import java.time.Duration;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient aiWebClient() {
        return WebClient.builder()
                .baseUrl("http://[AI 서버의 IP 주소]")
                .clientConnector(new ReactorClientHttpConnector(
                        HttpClient.create()
                                .responseTimeout(Duration.ofSeconds(30))
                ))
                .build();
    }
}

WebClientConfig 클래스에서 WebClient 빈을 등록하여, AI 서버의 IP 주소를 기본 URL로 설정한다. 이로써 다른 서비스에서 @Autowired 또는 생성자 주입을 통해 aiWebClient를 쉽게 사용할 수 있다.

 

2. AI 요청 전담 클라이언트 클래스 (AiClient)

package com.chapssal_tteok.preview.global.client;

import com.chapssal_tteok.preview.domain.interviewqa.dto.InterviewQaRequestDTO;
import com.chapssal_tteok.preview.domain.resumeqa.dto.ResumeQaRequestDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

import java.util.List;
import java.util.Map;

@Component
@RequiredArgsConstructor
public class AiClient {

    private final WebClient aiWebClient;

    public String analyzeResumeQa(ResumeQaRequestDTO.AnalyzeResumeQaDTO req) {
        Map<String, String> body = Map.of(
                "question", req.getQuestion(),
                "resume", req.getResume(),
                "company", req.getCompany(),
                "position", req.getPosition()
        );

        return aiWebClient.post()
                .uri("/interview/analyze-resume")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, List<String>>>() {})
                .map(res -> {
                    List<String> feedbackList = res.get("feedback");
                    return (feedbackList != null && !feedbackList.isEmpty()) ? String.join("\n", feedbackList) : null;
                })
                .block();
    }

    public String generateQuestion(InterviewQaRequestDTO.GenerateQuestionDTO req) {
        Map<String, Object> body = Map.of(
                "company", req.getCompany(),
                "position", req.getPosition(),
                "resumeContent", req.getResumeContent()
        );

        return aiWebClient.post()
                .uri("/interview/generate-qas")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, List<String>>>() {})
                .map(res -> {
                    List<String> questions = res.get("questions");
                    return (questions != null && !questions.isEmpty()) ? String.join("\n", questions) : null;
                })
                .block();
    }

    public String generateFollowUp(InterviewQaRequestDTO.GenerateFollowUpDTO req) {
        Map<String, String> body = Map.of(
                "question", req.getQuestion(),
                "answer", req.getAnswer()
        );

        return aiWebClient.post()
                .uri("/interview/follow-up")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, List<String>>>() {})
                .map(res -> {
                    List<String> followUps = res.get("followUps");
                    return (followUps != null && !followUps.isEmpty()) ? String.join("\n", followUps) : null;
                })
                .block();
    }

    public String analyzeAnswer(String question, String answer, String resume) {
        Map<String, String> body = Map.of(
                "question", question,
                "answer", answer,
                "resume", resume
        );

        return aiWebClient.post()
                .uri("/interview/analyze-answer")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, String>>() {})
                .map(res -> res.getOrDefault("analysis", null))
                .block();
    }
}

AI 서버 호출 로직은 AiClient라는 별도 클래스로 분리되어 있다.
예를 들어 /interview/analyze-answer 같은 FastAPI 엔드포인트를 POST로 호출하여, 자기소개서 문항 및 답변 분석 결과를 받아온다.

 

3. 서비스 계층에서 AI 응답 활용

package com.chapssal_tteok.preview.domain.resumeqa.service;

import com.chapssal_tteok.preview.domain.resume.entity.Resume;
import com.chapssal_tteok.preview.domain.resume.repository.ResumeRepository;
import com.chapssal_tteok.preview.domain.resumeqa.converter.ResumeQaConverter;
import com.chapssal_tteok.preview.domain.resumeqa.dto.ResumeQaRequestDTO;
import com.chapssal_tteok.preview.domain.resumeqa.entity.ResumeQa;
import com.chapssal_tteok.preview.domain.resumeqa.repository.ResumeQaRepository;
import com.chapssal_tteok.preview.domain.user.entity.Role;
import com.chapssal_tteok.preview.domain.user.entity.User;
import com.chapssal_tteok.preview.global.apiPayload.code.status.ErrorStatus;
import com.chapssal_tteok.preview.global.apiPayload.exception.handler.ResumeHandler;
import com.chapssal_tteok.preview.global.apiPayload.exception.handler.ResumeQaHandler;
import com.chapssal_tteok.preview.global.apiPayload.exception.handler.UserHandler;
import com.chapssal_tteok.preview.global.client.AiClient;
import com.chapssal_tteok.preview.security.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ResumeQaCommandServiceImpl implements ResumeQaCommandService {

    private final ResumeQaRepository resumeQaRepository;
    private final SecurityUtil securityUtil;
    private final AiClient aiClient;
    
    @Override
    @Transactional
    public ResumeQa analyzeResumeQa(Long resumeId, Long qaId, ResumeQaRequestDTO.AnalyzeResumeQaDTO request) {

        // 현재 로그인된 사용자 정보 가져오기
        User user = securityUtil.getCurrentUser();

        ResumeQa resumeQa = resumeQaRepository.findById(qaId)
                .orElseThrow(() -> new ResumeQaHandler(ErrorStatus.RESUME_QA_NOT_FOUND));

        if (!resumeQa.getResume().getId().equals(resumeId)) {
            throw new ResumeQaHandler(ErrorStatus.RESUME_QA_NOT_MATCH);
        }

        if (!user.getId().equals(resumeQa.getResume().getUser().getId()) && !user.getRole().equals(Role.ADMIN)) {
            throw new UserHandler(ErrorStatus.USER_NOT_AUTHORIZED);
        }

        String analysis = aiClient.analyzeResumeQa(request);

        resumeQa.updateAnalysis(analysis);

        return resumeQaRepository.save(resumeQa);
    }
}

AI 서버로부터 받은 분석 결과는 resumeQa.updateAnalysis(...)와 같이 ResumeQa 엔티티에 바로 반영되며, DB에 저장된다. 

 

백엔드 서버 쪽에 API 요청을 보냈을 때 AI 서버와도 잘 연동되어 자기소개서 분석 결과가 나오는 것을 확인할 수 있다.

 

Comments