[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 는 데이터베이스에 접근하고 데이터를 읽고 쓰는 등의 데이터 액세스 작업을 수행한다. 주로 데이터베이스와의 연결 및 쿼리를 처리하는 코드로 구성된다.
- Router 함수에 작성되어있던 SQLAlchemy 로직을 모두
- 또한 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 으로 정의한
ResponseModel
에from_dto
등의 메소드를 정의하고, 반환받은 결과 DTO 를 해당 DTO 를 처리하는 메소드를 통해 Response 결과를 반환하도록 한다. - 추가적으로 Router 함수의 argument 에 FastAPI 의
Path, Query, Body
를 설정하고 description 을 추가할 수 있다. 여기에 Docstring 을 추가하면, Swagger 에서 확인할 수 있다.
- 또한 Pydantic 으로 정의한
-
최종적으로 아래와 같이 프로젝트를 구조화할 수 있다.
. ├── 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 값을 전달하게 된다.
- 이는 Container 내에서 DB URL 과 같은 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
댓글 남기기