[FastAPI] FastAPI 개발 흐름


앞서 정리한 내용들을 이용하여 FastAPI 웹 서비스를 간단하게 구현해보기 전에, 이 포스트에서는 실제 개발의 흐름을 알아보고, 개발이 완료되었을 때의 프로젝트 구조를 보면서 감을 잡아보도록 하자.

FastAPI 개발 흐름

  • 먼저 전체 구조를 생각한다. predict.py, api.py, config.py 등의 파일, 폴더 구조를 어떻게 할지 생각할 수 있다.
  • 더 나아가 3 tier layer, 4 tier layer 등 계층화 아키텍처(Layered Architecture)를 고민해볼 수 있다.

Layered Architecture

  • Layered Architecture 는 소프트웨어 시스템을 여러 계층(Layers)으로 나누어 구성하는 설계 패턴이다. 각 계층은 특정한 역할과 책임을 가지며, 계층 간에는 엄격한 인터페이스를 통해 상호 작용한다.
  • Layer 는 필요에 따라 언제든지 추가될 수 있다.
  • 스파게티 코드가 될 가능성이 있기 때문에, 같은 Layer 내에서 서로 다른 객체가 서로 참조하지 않도록 주의해야 한다.
  • 주로 다음과 같은 세 가지 주요 계층으로 구성된다.

표현 계층 (Presentation Layer 또는 Controller Layer)

  • 사용자와의 상호 작용을 처리한다. 즉 클라이언트의 요청을 받아 해당 요청을 서비스 계층으로 전달한다.
  • 주로 웹 애플리케이션에서는 컨트롤러(Controller)로 구성되며, 사용자 입력을 처리하고 적절한 서비스에 전달한다.
  • FastAPI 에서 Router 함수(API)에 해당한다고 볼 수 있다.
    • API 는 건물의 문과 같은 외부 경로와 같은 역할을 한다. 즉 외부와 통신하는 역할이다.
    • 예를 들어 클라이언트에서 API 를 호출하면 모델의 결과를 Return 하는 방식이다.
  • 사용자의 입력을 처리해서 적절한 계층에 전달하기 위해, FastAPI 에서 사용되는 개념 중에 schema 라는 것이 있다. 이는 자바의 DTO(Data Transfer Object)와 비슷한 개념으로, 네트워크를 통해 데이터를 주고 받을 때 어떤 형태로 주고 받을지를 정의한다.

응용 계층 (Application Layer 또는 Service Layer)

  • 비즈니스 로직을 구현하고, 클라이언트의 요청을 처리한다. 즉 ML 모델이 예측하거나 추론하는 실제 로직을 구현한다.
  • 표현 계층으로부터 받은 요청을 분석하고, 필요한 데이터를 가져와 비즈니스 로직을 수행한다.
  • 데이터 액세스 계층과 통신하여 데이터를 읽고 쓰는 역할을 수행한다.

데이터 액세스 계층 (Data Access Layer 또는 Persistence Layer)

  • 데이터베이스나 외부 데이터 소스와의 상호 작용을 담당한다. 또한 외부 API 요청도 포함한다.
  • 데이터베이스에 접근하고 데이터를 읽고 쓰는 등의 데이터 액세스 작업을 수행한다.
  • 주로 데이터베이스와의 연결 및 쿼리를 처리하는 코드로 구성된다.

Layered Architecture 가 중요한 이유

  • 모듈화와 분리
    • 각 레이어는 독립적으로 구현되므로, 시스템의 다른 부분에 영향을 미치지 않고 수정, 유지보수, 확장이 가능하다.
    • 예를 들어, 데이터 액세스 레이어에서 데이터베이스 접근 방법이 변경되어도 서비스 레이어 및 컨트롤러 레이어는 영향을 받지 않는다.
  • 재사용성
    • 각 레이어는 독립적으로 구성되어 있으므로 재사용이 용이하다.
    • 예를 들어, 서비스 레이어의 특정 기능을 다른 컨트롤러에서도 사용할 수 있다.
  • 테스트 용이성
    • 각 레이어는 독립적으로 테스트할 수 있다. 이는 단위 테스트, 통합 테스트 등을 수행할 때 유용하다.
    • 또한 Mocking 을 사용하여 특정 레이어의 의존성을 제거하고 테스트하기 쉽게 만들 수 있다.
  • 확장성
    • 새로운 기능 또는 요구 사항이 추가될 때, 새로운 레이어를 추가하거나 기존의 레이어를 수정하여 시스템을 쉽게 확장할 수 있다.
    • 예를 들어, 새로운 데이터 소스를 지원하기 위해 데이터 액세스 레이어를 추가할 수 있다.
  • 관심사의 분리
    • 각 레이어는 특정한 관심사에만 집중하므로 코드의 가독성과 이해도를 향상시킨다.
    • 데이터 액세스 레이어는 데이터베이스와의 상호 작용에만 집중하고, 서비스 레이어는 비즈니스 로직을 처리하고, 컨트롤러 레이어는 요청과 응답을 처리한다.

DTO 와 VO

  • DTO (Data Transfer Object)
    • DTO 는 데이터 전송을 위해 사용되고, 주로 계층 간 데이터 전달을 위한 용도로 사용된다.
    • 예를 들어, 데이터베이스로부터 데이터를 가져와 서비스 계층으로 전달하거나, 서비스 계층에서 생성된 데이터를 표현 계층으로 전달하는 것에 사용된다.
    • DTO 는 특정 작업 또는 연산을 수행하는 데 필요한 데이터를 포함하며, 일반적으로 비즈니스 로직을 포함하지 않는다.
    • DTO 는 데이터의 전송을 위해 직렬화될 수 있어야 하며, 일반적으로 가변(mutable)한 객체다.
  • VO (Value Object)
    • VO 는 값 그 자체를 나타내는 데 사용된다. 데이터를 캡슐화하여 불변(immutable)한 형태로 사용한다.
    • VO 는 주로 도메인 모델의 부분으로 사용되며, 개념적으로는 데이터의 불변성과 불변성을 보장하는 데 중점을 둔다.
    • VO 는 동일성(Identity)이 아닌 값에 따라 동등성(Equality)을 판단한다. 즉, 두 VO 가 동일한 속성 값을 가지면 같은 것으로 간주된다.
    • 예를 들어, 금액이나 날짜와 같은 값을 나타내는 경우 VO 를 사용하여 이러한 값의 불변성을 보장할 수 있다.
  • Pydantic 의 Basemodel 이나, dataclass 를 사용해서 정의한다. 이 때 예측이나 서비스에 대한 Request 와 Response 를 고려한다.

Project Structure

  • 앞선 포스트에서 프로젝트 구조를 다룬 바 있다. 이 섹션에서는 Layered Architecture 가 적용된 프로젝트 구조도를 보자.
  • 먼저 Router 함수에 작성된 로직을 Repository Class(Data Access Layer), Service Class(Service Layer) 로 각 레이어의 역할과 책임에 맞도록 코드를 리펙토링할 수 있다.
    • Router 함수에 작성되어있던 SQLAlchemy 로직을 모두 Repository 라고 하는 클래스로 리팩토링 한다.
    • Repository Class 는 데이터베이스에 접근하고 데이터를 읽고 쓰는 등의 데이터 액세스 작업을 수행한다. 주로 데이터베이스와의 연결 및 쿼리를 처리하는 코드로 구성된다.
  • 또한 DTO 클래스를 정의하고 Controller Layer(Router) 와 Service Layer 간에 데이터 변환을 위한 메소드를 추가할 수 있다.
    • Service Layer 에서 Repository Layer 로 데이터를 전달하거나, Repository Layer 에서 Service Layer 로 데이터를 전달할 때 DTO 로 만들어서 반환할 수 있도록 DTO 클래스를 정의한다.
    • 추상화를 위해서 DTO 로 전달, 반환하는 것이 좋다.
  • Router 함수에 작성되어 있던 비즈니스 로직을 모두 Service 라고 하는 클래스로 리펙토링 한다.
    • Service Class 는 비즈니스 로직을 구현하고, 클라이언트의 요청을 처리한다. 또한 표현 계층(Router 함수)으로부터 받은 요청을 분석하고, 필요한 데이터를 가져와 비즈니스 로직을 수행한다.
    • 데이터 액세스 계층과 통신하여 데이터를 읽고 쓰는 역할을 수행한다.
  • 이를 통해 Router 함수 내에서 Service, Repository 클래스 인스턴스를 생성하고 필요한 메소드를 호출하도록 한다.
    • 또한 Pydantic 으로 정의한 ResponseModelfrom_dto 등의 메소드를 정의하고, 반환받은 결과 DTO 를 해당 DTO 를 처리하는 메소드를 통해 Response 결과를 반환하도록 한다.
    • 추가적으로 Router 함수의 argument 에 FastAPI 의 Path, Query, Body 를 설정하고 description 을 추가할 수 있다. 여기에 Docstring 을 추가하면, Swagger 에서 확인할 수 있다.
  • 최종적으로 아래와 같이 프로젝트를 구조화할 수 있다.

    .
    ├── Dockerfile                 # 애플리케이션의 Docker Container Image 를 빌드하기 위한 설정 파일
    ├── README.md                  # 프로젝트 개요, 설치 방법, 실행 방법 등을 설명한 문서
    ├── app                        # 실제 애플리케이션 코드가 포함된 디렉토리
    │   ├── core                       # 핵심 인프라 설정 및 전역적인 유틸리티 관리 계층 (설정, Middleware, DB, Redis 등)
    │   │   ├── auth.py                    # 인증 관련 유틸리티 함수 및 로직
    │   │   ├── config.py                  # 환경 변수, 설정값 정의 (ex. DB URL, Redis URL)
    │   │   ├── container.py               # 의존성 주입 설정 (ex. Dependency Injector)
    │   │   ├── db                         # 데이터베이스 관련 설정
    │   │   │   ├── __init__.py                # DB 서브모듈 초기화
    │   │   │   └── session.py                 # SQLAlchemy 세션 팩토리 및 연결 설정
    │   │   ├── errors                     # 전역 예외 및 에러 핸들링 정의
    │   │   │   ├── error.py                   # 사용자 정의 예외 클래스
    │   │   │   └── handler.py                 # FastAPI 예외 핸들러 등록
    │   │   ├── lifespan.py                # FastAPI lifespan 이벤트 처리 (앱 시작/종료 시 실행할 로직)
    │   │   ├── logger.py                  # 애플리케이션 로깅 설정 및 초기화
    │   │   ├── middlewares                # 커스텀 미들웨어 정의
    │   │   │   └── sqlalchemy.py              # DB 세션을 request lifecycle 에 연결하는 미들웨어
    │   │   └── redis.py                   # Redis 연결 및 유틸리티 설정
    │   ├── main.py                    # FastAPI 애플리케이션의 엔트리포인트 (app 생성, 라우터 등록)
    │   ├── models                     # 모델 계층: DB 모델, 스키마, DTO 등 데이터 정의
    │   │   ├── constant.py                # 전역 상수 정의 (예: 권한 등 공통 열거형)
    │   │   ├── db                         # ORM 모델 정의. DB 테이블과 매핑되는 클래스들 (SQLAlchemy 모델)
    │   │   │   └── db_class.py
    │   │   ├── dtos                       # 내부 비즈니스 계층 간 데이터 전달 객체 (DTO)
    │   │   │   ├── common.py                  # 공통적으로 사용되는 DTO 정의
    │   │   │   └── other_dtos.py
    │   │   └── schemas                    # Pydantic 기반의 요청/응답 스키마 (API I/O 정의)
    │   │       ├── common.py                  # 공통 필드/에러 응답 등 스키마 정의
    │   │       └── other_schemas.py
    │   ├── repositories               # 데이터 엑세스 계층 (DB 쿼리/저장 로직을 처리)
    │   │   ├── __init__.py                
    │   │   └── repository_class.py        # 여러 DB 액세스 로직 구현
    │   ├── routers                    # API 라우터(표현, Controller) 계층 (엔드포인트 정의)
    │   │   ├── __init__.py                # 라우터 모듈 초기화 및 통합
    │   │   └── other_routers.py           # 여러 API 정의
    │   └── services                   # 응용 계층 (유즈케이스 및 ML 등 비즈니스 로직 처리)
    │       ├── __init__.py                # 서비스 모듈 초기화
    │       └── other_service.py           # 여러 관련 비즈니스 로직 구현
    ├── entrypoint.sh              # Docker Container 시작 시 실행되는 셸 스크립트
    ├── poetry.lock                # Poetry 패키지 버전 잠금 파일
    ├── pyproject.toml             # 프로젝트의 종속성 및 빌드 설정 파일
    ├── pytest.ini                 # pytest 설정 파일
    ├── start.sh                   # 로컬 서버 실행 스크립트
    ├── test.sh                    # 테스트 실행 스크립트
    └── tests                      # 테스트 코드 디렉터리
        ├── __init__.py                
        └── app                        
          ├── __init__.py                  
          ├── conftest.py                  # 공통 pytest fixture 설정 (ex. 테스트 DB 세션 등)
          ├── router                       # 라우터 단위 테스트
          │   └── test_router.py
          └── service                      # 서비스 단위 테스트
              └── test_service.py
    

Dependency Injector

  • 위 프로젝트 구조에서 ./app/core/container.py 는 앞선 포스트에서 본 의존성 주입의 역할을 한다.
  • 이 의존성 주입에는 FastAPI 의 Depends() 외에 Dependency Injector 라이브러리를 많이 사용한다.
  • 각 Router 함수마다 필요한 객체들을 생성하여 사용하면서 동일한 코드를 매번 적는 것은, 코드의 중복이 발생할 뿐만 아니라 수정이 필요한 경우에 모두 수정해야하는 등 유지보수와 코드 관리 측면에서 비효율적일 수 있다.
  • Dependency Injector 를 사용하면 Container 를 중심으로 필요한 객체들을 정의한 뒤 wire 를 통해서 의존성을 주입할 모듈을 지정한다. 의존성 주입이 필요한 곳에서는 Provider 를 통해 정의된 객체를 생성해 주입할 수 있다.
    • 주입할 객체들을 정의하는 곳을 Container 라고 한다. 그리고 각각의 객체들을 정의하고 합쳐주는 Provider 를 사용해 정의한다.
    • 다양한 Provider 들이 있고, 목적에 따라 맞는 Provider 를 선택해서 사용한다. (참고 링크)
    • 대표적으로 주입 시 객체를 새로 생성해서 주입하는 Provider 인 Factory Provider, 주입 시 기존 객체가 존재한다면 기존 객체를 주입하는 Singleton Provider, 프로젝트 설정 값을 생성해서 주입하는 Configuration Provider 가 있다.
  • containers.DeclarativeContainer 를 상속받는 Container 클래스를 정의하고, containers.WiringConfiguration 을 통해 의존성을 주입 받을 모듈을 작성한다.
  • Container 클래스 내부의 Configuration Provider 의 경우 프로젝트 폴더의 config 를 주입할 Provider 이고, Container 생성 후 값을 할당한다.
    • 이는 Container 내에서 DB URL 과 같은 config 값을 사용해야하는 경우에 사용한다. main.py 에서 config provider 에 config 값을 전달하게 된다.
  • 나머지 필요한 repository, service 객체에는 Factory Provider 를 정의할 수 있다.
  • Container 클래스에서 WiringConfiguration 로 주입할 모듈을 지정해주지만, 실제로 필요한 곳에 주입을 할 때는 @inject 라는 데코레이터를 추가 해줘야 한다.
    • 각 엔드포인트 함수마다 @inject 데코레이터 및 Depends, dependency_injector.wiring.Provide 를 통해 Container 에 정의한 객체를 주입할 수 있다.
    • 의존성 주입은 Router 함수 말고도 다른 함수들에도 동일하게 wiring config 로 모듈 추가 및 @inject 를 추가하여 사용할 수 있다.
  • Dependency Injector 의 경우 후에 실제 프로젝트를 구현할 때 사용해보자.

더 고려할 부분

  • 개발을 진행하면서 더 고민하고 발전시킬 부분은 아래와 같다.
    • Dev(개발), Prod(운영) 환경 구분에 따라 어떻게 구현할 것인가?
    • Data Input / Output 고려
    • 현재의 Database 서버에서 Cloud Database(AWS Aurora, GCP Cloud SQL) 로 교체할 것인가?
    • API 서버 모니터링
    • API 부하 테스트
    • Test Code
맨 위로 이동 ↑

댓글 남기기