[Spring][FastAPI]FastAPI에서 Spring으로 마이그레이션하며 배운 점

JUNYOUNG·2025년 3월 7일
post-thumbnail

1. FastAPI를 선택했던 이유

FastAPI는 가볍고 빠르게 API를 개발할 수 있는 프레임워크다. 비동기 I/O를 쉽게 지원하며, Pydantic을 활용한 데이터 검증과 직렬화가 편리하다. 하지만 내가 FastAPI를 사용했던 이유는 비동기 처리 때문이 아니라 단순히 사용하기 편하고 경량화된 프레임워크였기 때문이었다.

FastAPI를 사용하면서 API 개발 자체는 간결하고 효율적이었지만, 점점 기능이 확장되면서 더 복잡한 요구사항을 처리해야 했다. 그 과정에서 Spring으로 마이그레이션할 필요성이 생겼다.

2. Spring으로 마이그레이션하며 겪은 어려움

Spring은 개발자에게 많은 기능을 제공해주는 편리한 프레임워크지만, 처음 실무에서 접했을 때 여러 가지 어려움이 있었다. 특히 JPA 연관관계 설정과 자동 구성(Auto Configuration), 상속 관계에서 발생하는 문제에서 많은 시행착오를 겪었다.

2.1 JPA 성능 최적화: Fetch Join vs 네이티브 쿼리, 페이징 충돌 문제 해결

FastAPI에서는 SQLAlchemy를 사용하여 데이터 조회 최적화를 진행했지만, Spring에서는 JPA(Hibernate)를 사용하면서 여러 성능 이슈가 발생했다. 특히 연관관계를 맺은 엔티티를 조회할 때 불필요한 조인과 다량의 쿼리가 실행되는 문제를 해결하는 과정에서 중요한 경험을 얻었다.

2.1.1 문제 상황

  • Fetch Join의 장점과 한계
    • 연관된 엔티티를 한 번의 쿼리로 가져올 수 있어 N+1 문제를 해결할 수 있음.
    • 하지만 컬렉션을 포함한 Fetch Join은 페이징과 충돌하는 문제가 있음.
    • LIMIT이나 OFFSET을 사용할 경우 데이터베이스 내부적으로 모든 데이터를 조회한 후 페이징을 적용하기 때문에 성능 저하 발생.
  • 네이티브 쿼리 활용의 필요성
    • 특정 경우에는 Fetch Join 대신 네이티브 쿼리를 활용하는 것이 성능적으로 더 유리했음.
    • 복잡한 다중 조인 시, JPA의 JPQL보다 직접 SQL을 작성하여 최적화하는 것이 더 효과적.
    • 인덱스 힌트 및 서브쿼리를 활용해 불필요한 조인을 제거.

2.1.2 해결 방법

Case 1: 단순한 1:N 조회 → Fetch Join 사용

  • @EntityGraph 또는 JOIN FETCH를 사용하여 단일 쿼리로 데이터를 가져옴.
  • ex) SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id
  • Fetch Join은 데이터가 적고 페이징이 필요 없는 경우 가장 효과적.

Case 2: 다대다 관계 + 페이징 → 네이티브 쿼리 적용

  • JPA의 createNativeQuery를 활용하여 페이징 쿼리를 직접 작성.
  • ex) SELECT * FROM orders o JOIN users u ON o.user_id = u.id WHERE u.status = 'ACTIVE' LIMIT 10 OFFSET 0
  • JPA의 Fetch Join을 사용하면 불필요한 모든 데이터를 조회한 후 페이징이 적용되므로, 네이티브 SQL을 사용하여 성능 개선.

Case 3: 대량 데이터 조회 최적화 → DTO Projection 적용

  • 엔티티를 직접 조회하는 것이 아니라, JPQL을 활용한 DTO 매핑을 사용하여 불필요한 필드 조회를 줄임.
  • ex) SELECT new com.example.dto.UserOrderDTO(u.id, u.name, o.id, o.totalPrice) FROM User u JOIN u.orders o WHERE u.status = 'ACTIVE'
  • 필요하지 않은 연관관계까지 불러오는 JPA 기본 동작을 차단하여 성능 최적화.

2.1.3 성능 개선 결과

  • Fetch Join으로 N+1 문제를 해결한 경우
    • 기존: API 호출 시 평균 500ms → 개선 후 120ms로 단축 (약 4배 속도 개선)
    • 원인: 개별 SQL 호출이 많았던 문제 해결
  • 네이티브 쿼리 적용 후 페이징 최적화한 경우
    • 기존: 페이징 요청 시 평균 900ms → 개선 후 250ms로 단축 (약 3.6배 개선)
    • 원인: Fetch Join으로 인해 전체 데이터가 로딩된 후 페이징 적용되는 비효율적 동작을 네이티브 SQL로 변경하여 해결
  • DTO Projection을 적용한 경우
    • 기존: 엔티티 조회 후 불필요한 필드 포함하여 전송 (800ms) → 필요한 필드만 조회 후 300ms로 단축
    • 원인: 불필요한 필드까지 로드되면서 쿼리 최적화가 되지 않던 문제 해결

이 과정을 통해 JPA의 기본 동작을 그대로 따르는 것이 항상 최적은 아니며, 상황에 맞게 Fetch Join, 네이티브 쿼리, DTO Projection을 적절히 활용해야 한다는 점을 체감했다.

2.1.4 CQRS 패턴 적용

이 과정에서 CQRS(Command Query Responsibility Segregation) 패턴을 알게 되었고, 읽기와 쓰기를 분리하는 방식이 성능 최적화에 적절하다는 점을 체감했다.

CQRS 패턴이란?

CQRS(Command Query Responsibility Segregation)은 읽기(쿼리)와 쓰기(커맨드)의 책임을 분리하는 아키텍처 패턴이다. 단일 데이터 모델을 사용하여 읽기와 쓰기를 모두 수행하는 전통적인 방식과 달리, 읽기 전용 모델과 쓰기 전용 모델을 분리함으로써 성능 최적화와 확장성을 높일 수 있다. 이 패턴을 적용하면서, 복잡한 조회는 별도의 최적화된 쿼리를 활용하고, 쓰기 연산은 도메인 로직을 통해 일관성을 유지하는 접근 방식을 고려했다.

2.2 상속 관계에서의 Discriminator Column(DTYPE) 문제

JPA에서 @Inheritance(strategy = InheritanceType.JOINED)을 사용하여 상속 관계를 정의할 때, @DiscriminatorColumn을 통해 DTYPE을 명시적으로 관리했다. 그러나, 필드 셰도윙(Field Shadowing) 문제로 인해 데이터가 null이 되는 문제가 발생했다.

2.2.1. 문제 상황

  • 부모 클래스와 자식 클래스에서 같은 이름의 변수를 정의하면, JPA가 올바르게 매핑하지 못하는 현상이 발생.
  • 이를 통해 특정 필드 값이 null로 저장되거나 조회할 때 잘못된 데이터가 반환되는 이슈가 발생.
  • 특히, @MappedSuperclass를 사용하지 않고 SINGLE_TABLE 전략을 사용할 경우 DTYPE이 예상과 다르게 동작할 가능성이 있음.

2.2.2. 해결 방법

  • 부모 클래스와 자식 클래스 간의 중복 필드 제거.
  • 필요한 경우 DTO 변환을 통해 명확하게 데이터 매핑을 수행.
  • JOINED 전략을 사용했지만, 필드 셰도윙 문제로 인해 데이터 정합성을 유지하기 어려운 경우가 발생할 수 있음. 이를 방지하기 위해 부모 클래스와 자식 클래스 간의 필드 중복을 최소화하고, 명확한 데이터 매핑을 수행해야 함..

이 과정에서 JPA의 동작 방식과 상속 매핑 전략을 더욱 깊이 이해하게 되었으며, ORM을 사용할 때 발생할 수 있는 예기치 않은 문제들에 대한 대응력을 키울 수 있었다.

2.3 자동 구성(Auto Configuration)과 제어의 역전(IoC)

Spring은 다양한 자동 구성을 제공하여 개발자가 직접 설정하지 않아도 편리하게 사용할 수 있도록 지원한다. 하지만 이러한 자동 구성은 내부 동작을 정확히 이해하지 못하면 오히려 장애를 유발할 수 있다.

2.3.1. 문제 상황

  • Spring Boot가 제공하는 ObjectMapper, Jackson 등의 자동 설정을 잘 모르고 사용하면 디버깅이 어려운 문제가 발생.
  • 특정 설정을 덮어씌우는 바람에 예상과 다른 동작이 나오는 경우가 있었음.
  • 디버깅 과정에서 IoC(제어의 역전, Inversion of Control)를 이해해야 했음.

2.3.2. 해결 방법

  • 자동 설정이 어떻게 동작하는지 명확히 이해하고, 필요한 경우 수동으로 설정 값을 지정.
  • @ConfigurationProperties, @Bean 등을 활용하여 원하는 설정을 명확히 정의.
  • application.yml에서 설정 값을 명확히 관리하여 불필요한 오버라이딩을 방지.

이 과정에서 제어의 역전(IoC)이란 무엇인지 더 깊이 이해하게 되었고, Spring이 많은 것을 자동으로 해주지만 결국 개발자가 도구를 정확히 이해하고 사용해야 한다는 점을 배웠다.

3. 마이그레이션을 통해 배운 점

Spring으로 마이그레이션하면서 가장 크게 배운 점은 다음과 같다.

3.1. FastAPI는 비동기 I/O 때문이 아니라 경량화된 구조가 좋아서 선택했지만, 대규모 서비스에서는 Spring의 강력한 지원이 필요했다.

3.2. JPA 연관관계를 잘못 설정하면 성능이 심각하게 저하될 수 있으며, 이를 해결하기 위해 CQRS 패턴과 네이티브 쿼리를 활용하는 것이 효과적이었다.

3.3. 상속 관계에서 Discriminator Column을 활용할 때, 필드 셰도윙 문제를 방지하기 위해 데이터 매핑을 명확하게 해야 한다.

3.4. Spring의 자동 구성은 개발을 편리하게 해주지만, 내부 동작을 모르면 예상치 못한 문제가 발생할 수 있다.

3.5. 제어의 역전(IoC) 개념을 체감하면서, 프레임워크가 자동으로 처리해주는 것들을 정확히 이해하고 활용해야 한다는 점을 깨달았다.

4. 결론

FastAPI와 Spring은 각각 장단점이 있으며, 특정 기술을 선택할 때는 그 기술이 필요한 이유를 정확히 이해하는 것이 중요하다. FastAPI는 빠른 개발과 간결한 구조가 강점이었지만, 복잡한 비즈니스 로직을 다루고 대규모 트래픽을 처리하는 데는 Spring이 더 적합했다.

profile
Onward, Always Upward - 기록은 성장의 증거

0개의 댓글