1. 도입: 리팩토링을 결심한 이유
이번 프로젝트에서는 기존에 동작하던 AI 백엔드 서버의 내부 구조를 개선하는 리팩토링을 진행했다.
초기 버전의 코드는 빠른 기능 구현에 초점이 맞춰져 있었지만, 프로젝트가 확장되면서 다음과 같은 문제점들이 드러났다.
- 유지보수의 어려움: 하나의 파일에 너무 많은 코드가 집중되어 있어, 수정이 필요할 때 전체 코드를 파악해야 했다.
- 확장성 부족: 새로운 기능을 추가할 때 기존 코드를 수정하는 것에 부담이 있었고, 코드 간 의존성이 높아 테스트가 어려웠다.
- 안정성 문제: 예외 처리가 포괄적이어서, 에러 발생 시 원인을 빠르게 파악하기 힘들었다.
이러한 문제들을 해결하고, 더 안정적이고 유연한 백엔드 시스템을 만들기 위해 -Python AI 서버(FastAPI)-와 -Java 메인 서버(Spring Boot)- 두 부분에 걸쳐 리팩토링을 시작했다.
2. Part 1: Python AI 서버 구조 개선 (FastAPI)
목표: 단일 파일 스크립트에서 계층형 애플리케이션으로
가장 시급한 문제는 모든 로직이 main.py 한 파일에 담겨 있던 Python AI 서버였다.
이를 -관심사 분리 원칙(SoC)- 에 따라 역할별로 파일을 나누는 작업을 진행했다.
Before: 모든 것이 담겨있던 main.py
리팩토링 이전의 main.py 코드는 다음과 같다.
API 설정, 경로 정의, AI 모델 호출, DB 연동 로직이 모두 한 곳에 있었다.
# ai-server/main.py (리팩토링 전)
import os
import uuid
import base64
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
from langchain_core.documents import Document
import uvicorn
from dotenv import load_dotenv
from typing import Optional
import anthropic
# .env 파일 로드
load_dotenv()
app = FastAPI()
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 1. API 키 설정
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
print("Warning: 현재 실존하지 않는 API키 입니다. 다시 확인해주세요.")
# 2. Claude 클라이언트 초기화
client = anthropic.Anthropic(api_key=api_key)
# 3. 임베딩 모델 설정
embeddings = OllamaEmbeddings(model="nomic-embed-text")
# 4. ChromaDB 설정
vector_store = Chroma(
collection_name="study_notes",
embedding_function=embeddings,
persist_directory="./chroma_db"
)
@app.post("/analyze-image")
async def analyze_image(
prompt: str = Form(...),
file: Optional[UploadFile] = File(None)
):
try:
# ... (AI 모델 호출 및 DB 저장 로직) ...
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/search-memory")
def search_memory(query: str):
results = vector_store.similarity_search(query, k=3)
return {"results": [doc.page_content for doc in results]}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
After: 역할별로 분리된 계층형 구조
리팩토링 후, ai-server는 다음과 같이 명확한 역할 분담을 갖는 구조로 재탄생했다.
- main.py: FastAPI 앱 생성 및 모듈 조립 역할.
- app/config.py: 모든 설정을 중앙에서 관리.
- app/services.py: 실제 비즈니스 로직(AI 호출, DB 작업) 수행.
- app/routers.py: API 경로를 정의하고, 요청을 서비스에 전달.
app/config.py(설정 관리)
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
ANTHROPIC_API_KEY: str
CHROMA_DB_PATH: str = "./chroma_db"
# ... (기타 설정)
settings = Settings()
app/services.py(비즈니스 로직)
import anthropic
from langchain_chroma import Chroma
from .config import settings
class AIService:
def __init__(self):
self.client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
self.vector_store = Chroma(...)
# ...
async def analyze_content(self, prompt: str, file: Optional[UploadFile] = None) -> dict:
# ... (AI 모델 호출 및 DB 저장 로직)
pass
def search_memory(self, query: str) -> dict:
# ... (DB 검색 로직)
pass
ai_service = AIService()
app/routers.py(API 경로 정의)
from fastapi import APIRouter, Depends
from .services import AIService, ai_service
router = APIRouter()
def get_ai_service() -> AIService:
return ai_service
@router.post("/analyze-image")
async def analyze_image(..., service: AIService = Depends(get_ai_service)):
return await service.analyze_content(prompt, file)
# ... (다른 라우트)
main.py(애플리케이션 조립)
from fastapi import FastAPI
from app.config import settings
from app.routers import router as api_router
app = FastAPI()
app.include_router(api_router, prefix="/api/v1")
# ... (CORS 설정 등)
개선 결과
이러한 구조 변경을 통해 각 파일의 책임이 명확해졌고, FastAPI의 의존성 주입(Dependency Injection) 시스템을 활용하여 서비스와 라우터를 분리함으로써 코드의 재사용성과 테스트 용이성이 크게 향상 되었다.
3. Part 2: Java 서비스 레이어 개선 (Spring Boot)
목표: 더욱 스프링답게, 안정적으로
Java 메인 서버에서는 Python AI 서버를 호출하는 AiService의 코드를 더 안정적이고 효율적으로 만드는 데 집중했다.
Before: 수동 JSON 파싱과 포괄적인 예외 처리
기존 코드는 AI 서버로부터 받은 응답을 문자열(String)로 받은 뒤,
ObjectMapper를 사용해 수동으로 DTO 객체로 변환했다. 또한 모든 예외를 Exception으로 한 번에 처리하여 원인 파악이 어려웠다.
// AiService.java (리팩토링 전)
@Transactional
public AiResponseDto analyzeImage(User user, MultipartFile file, String prompt) {
// ...
String rawJson = webClient.post()
.uri("/analyze-image")
.retrieve()
.bodyToMono(String.class)
.block();
try {
AiResponseDto response = objectMapper.readValue(rawJson, AiResponseDto.class);
aiDatabaseService.saveAnalysisResult(user, prompt, response);
return response;
} catch (Exception e) {
log.error("JSON 파싱 또는 DB 저장 중 사고 발생: {}", e.getMessage());
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
After: 자동 변환과 세분화된 예외 처리
WebClient가 제공하는 강력한 기능들을 활용하여 코드를 대폭 개선했다.
- DTO 자동 변환: bodyToMono(AiResponseDto.class)를 사용하여 WebClient가 직접 JSON을 DTO로 변환하도록 했다.
- 안전한 블로킹: .block() 대신 .blockOptional().orElseThrow()를 사용하여 null 응답 가능성을 원천 차단했다.
- 세분화된 예외 처리: WebClientResponseException 등 구체적인 예외를 catch하여 AI 서버의 응답 오류, 네트워크 오류 등을 명확하게 구분하고 로깅하도록 변경했다.
// AiService.java (리팩토링 후)
@Transactional
public AiResponseDto analyzeImage(User user, MultipartFile file, String prompt) {
// ...
try {
AiResponseDto response = webClient.post()
.uri("/api/v1/analyze-image")
.retrieve()
.bodyToMono(AiResponseDto.class) // 1. 자동 변환
.blockOptional() // 2. 안전한 블로킹
.orElseThrow(() -> new CustomException(ErrorCode.AI_SERVER_ERROR));
aiDatabaseService.saveAnalysisResult(user, prompt, response);
return response;
} catch (CustomException e) { // 3. 세분화된 예외 처리
log.error("사용자 정의 예외 발생: {}", e.getMessage());
throw e;
} catch (WebClientResponseException e) {
log.error("AI 서버 응답 오류 발생: 상태 코드 = {}", e.getStatusCode());
throw new CustomException(ErrorCode.AI_SERVER_ERROR);
} catch (Exception e) {
log.error("예상치 못한 사고 발생: {}", e.getMessage());
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
개선 결과:
이제 AiService는 더 이상 불필요한 JSON 파싱 책임을 갖지 않게 되었으며,
다양한 오류 상황에 대해 훨씬 더 명확하고 안정적으로 대응할 수 있게 되었다.
코드가 간결해진 것은 물론, 스프링 프레임워크의 기능을 온전히 활용하는 "스프링다운" 코드가 되었다.
4. Part 3: 테스트 코드로 리팩토링의 안정성 확보
목표: "잘 동작하겠지?"가 아닌, "잘 동작한다!"는 확신 갖기
리팩토링은 기존 기능의 변경 없이 코드의 내부 구조만 개선하는 작업이다.
따라서 리팩토링 후에도 모든 기능이 이전과 동일하게 잘 동작한다는 것을 보장하는 것이 매우 중요하다.
이를 위해 단위 테스트(Unit Test)와 통합 테스트(Integration Test)를 작성하여 코드의 안정성을 검증했다.
1. 단위 테스트 (Unit Test): 서비스 계층 검증
단위 테스트는 가장 작은 코드 단위(주로 메소드)를 독립적으로 테스트하는 것이다.
외부 의존성(DB, 다른 서비스 등)을 차단하기 위해 Mockito 라이브러리를 사용하여 가짜(Mock) 객체를 만들었다.
QuizService의 submitAnswer 메소드가 정답/오답 시나리오에 따라 올바르게 동작하는지 검증했다.
// QuizServiceTest.java
@ExtendWith(MockitoExtension.class)
class QuizServiceTest {
@InjectMocks // 테스트 대상. @Mock 객체들이 여기에 주입된다.
private QuizService quizService;
@Mock // 가짜(Mock) 객체로 만들 의존성
private QuizRepository quizRepository;
@Mock
private QuizResultRepository quizResultRepository;
@Test
@DisplayName("퀴즈 정답 제출 - 정답인 경우")
void submitAnswer_Correct() {
// given - 테스트 준비
Long quizId = 1L;
Quiz quiz = Quiz.builder().id(quizId).answer("2").build();
QuizSubmitRequest request = new QuizSubmitRequest(quizId, "2");
// quizRepository.findById가 호출되면, 준비된 quiz 객체를 반환하도록 설정
given(quizRepository.findById(quizId)).willReturn(Optional.of(quiz));
// when - 실제 메소드 호출
boolean isCorrect = quizService.submitAnswer(new User(), request);
// then - 결과 검증
assertThat(isCorrect).isTrue(); // 반환값이 true인지 확인
verify(quizResultRepository).save(any(QuizResult.class)); // save가 호출되었는지 확인
}
}
- @Mock: 실제 DB에 접근하는 QuizRepository 대신, 우리가 원하는 대로 동작하는 가짜 객체를 만들었다.
- given-when-then 패턴: 테스트의 준비-실행-검증 단계를 명확히 구분하여 가독성을 높였다.
2. 통합 테스트 (Integration Test): 컨트롤러 계층 검증
통합 테스트는 여러 컴포넌트를 함께 묶어, 실제 애플리케이션과 유사한 환경에서 테스트하는 것이다.
MockMvc를 사용하여 실제 HTTP 요청을 보내는 것처럼 컨트롤러의 API 엔드포인트를 테스트했다.
AiControllerTest.
java/api/ai/quiz/submit
엔드포인트가 정상적인 요청과 유효하지 않은 요청에 대해 올바르게 응답하는지 검증했다.
// AiControllerTest.java
@SpringBootTest
@AutoConfigureMockMvc
class AiControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean // 실제 서비스 대신 가짜(Mock) 서비스 객체를 스프링 컨텍스트에 등록
private AiService aiService;
@MockBean
private QuizService quizService;
@Test
@DisplayName("퀴즈 제출 API - 성공")
@WithMockCustomUser // 인증된 사용자가 요청한 것처럼 테스트
void submitAnswer_Success() throws Exception {
// given
QuizSubmitRequest request = new QuizSubmitRequest(1L, "2");
String requestJson = objectMapper.writeValueAsString(request);
// when & then
mockMvc.perform(post("/api/ai/quiz/submit")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isOk()); // 200 OK 응답을 기대
}
@Test
@DisplayName("퀴즈 제출 API - 실패 (quizId가 null)")
@WithMockCustomUser
void submitAnswer_Fail_NullQuizId() throws Exception {
// given
QuizSubmitRequest request = new QuizSubmitRequest(null, "2"); // 유효하지 않은 요청
String requestJson = objectMapper.writeValueAsString(request);
// when & then
mockMvc.perform(post("/api/ai/quiz/submit")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isBadRequest()); // 400 Bad Request 응답을 기대
}
}
- @SpringBootTest & @AutoConfigureMockMvc: 통합 테스트를 위해 스프링 컨텍스트와 MockMvc를 설정했다.
- @MockBean: 실제 서비스 로직이 실행되지 않도록 서비스 계층을 가짜 객체로 대체했다.
- mockMvc.perform(): 실제 HTTP 요청과 유사하게 API를 호출하고, andExpect()를 통해 응답 상태 코드를 검증했다.
개선 결과
테스트 코드를 작성함으로써, 리팩토링 과정에서 발생할 수 있는 잠재적인 버그를 사전에 발견하고 수정할 수 있었다.
또한, 앞으로 새로운 기능을 추가하거나 코드를 변경할 때, 기존 기능이 망가지지 않았다는 것을 테스트를 통해 빠르게 확인할 수 있는 안전망을 구축했다.