TIL

유지보수성을 높이는 백엔드 리팩토링 (FastAPI & Spring Boot)

ohs020105 2026. 3. 10. 18:05

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가 제공하는 강력한 기능들을 활용하여 코드를 대폭 개선했다.

  1. DTO 자동 변환: bodyToMono(AiResponseDto.class)를 사용하여 WebClient가 직접 JSON을 DTO로 변환하도록 했다.
  2. 안전한 블로킹: .block() 대신 .blockOptional().orElseThrow()를 사용하여 null 응답 가능성을 원천 차단했다.
  3. 세분화된 예외 처리: 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()를 통해 응답 상태 코드를 검증했다.

개선 결과

테스트 코드를 작성함으로써, 리팩토링 과정에서 발생할 수 있는 잠재적인 버그를 사전에 발견하고 수정할 수 있었다.

또한, 앞으로 새로운 기능을 추가하거나 코드를 변경할 때, 기존 기능이 망가지지 않았다는 것을 테스트를 통해 빠르게 확인할 수 있는 안전망을 구축했다.