FastAPI 구조 - 라우터, 3계층 아키텍처
FastAPI의 라우터 구조와 3계층 아키텍처가 무엇인지, 왜 이 구조로 나누는지 이해한다.
FastAPI란?
FastAPI는 Python으로 HTTP API 서버를 만드는 프레임워크다. 프레임워크란 "자주 필요한 기능을 미리 만들어둔 도구 모음"이다.
[FastAPI 서버]
├── 요청 받기 (HTTP 서버 내장)
├── 요청을 적절한 함수로 연결
├── 데이터 검증 (자동)
└── 응답 만들어서 돌려주기
위 기능들을 직접 만들 필요 없이, FastAPI가 다 해준다. 우리는 "어떤 요청이 왔을 때 어떤 데이터를 돌려줄지"만 작성하면 된다.
가장 짧은 FastAPI 서버
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
def hello():
return {"message": "Hello, FastAPI!"}
FastAPI(): 앱 객체 생성@app.get("/hello"): GET /hello 요청이 오면 아래 함수를 실행한다return값이 그대로 JSON 응답이 된다
터미널에서 uvicorn main:app --reload를 실행하면 서버가 뜬다.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process
http://localhost:8000/hello 로 접속하면 {"message": "Hello, FastAPI!"} 가 보인다.
데코레이터(@)란?
FastAPI에서 @app.get(...), @app.post(...) 같은 @ 기호를 데코레이터라고 한다.
데코레이터는 함수 위에 붙이는 "메모" 같은 것이다. "이 함수는 GET /hello 요청을 처리한다"고 FastAPI에게 알려주는 역할이다.
@app.get("/hello") # "GET /hello 요청이 오면 이 함수를 실행해"
def hello():
return "안녕하세요"
@app.post("/users") # "POST /users 요청이 오면 이 함수를 실행해"
def create_user():
return "유저 생성됨"
Spring Boot의 @GetMapping, @PostMapping과 같은 역할이다.
APIRouter - 코드 나누기
앱이 커지면 모든 경로를 main.py 한 파일에 넣기 어려워진다. FastAPI는 APIRouter로 경로를 파일별로 분리할 수 있다.
# users/router.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/users")
def get_users():
return []
@router.post("/users")
def create_user():
return {}
# main.py
from fastapi import FastAPI
from users import router as users_router
app = FastAPI()
app.include_router(users_router) # 라우터 등록
include_router()로 라우터를 앱에 연결한다. users/router.py가 /users 관련 경로를 담당한다.
Spring Boot의 @RestController를 별도 파일로 분리하는 것과 같다.
3계층 아키텍처 (3-Layer Architecture)
코드를 역할에 따라 3가지 계층으로 나누는 설계 방식이다.
┌──────────────────────────────────────────┐
│ Presentation Layer (표현 계층) │
│ Router (라우터) │
│ - HTTP 요청/응답 처리 │
│ - URL, 메서드 매핑 │
├──────────────────────────────────────────┤
│ Business Layer (비즈니스 계층) │
│ Service │
│ - 실제 비즈니스 로직 │
│ - 여러 Repository 조합 │
├──────────────────────────────────────────┤
│ Data Access Layer (데이터 접근 계층) │
│ Repository │
│ - DB 조회/저장/수정/삭제 │
└──────────────────────────────────────────┘
│
▼
Database
| 계층 | 역할 | FastAPI에서 | Spring Boot에서 |
|---|---|---|---|
| Presentation | HTTP 요청/응답 | APIRouter | @RestController |
| Business | 비즈니스 로직 | Service 클래스 | @Service |
| Data Access | DB 접근 | Repository 클래스 | @Repository |
왜 3개로 나누는가?
하나의 함수에 모든 코드를 넣으면 어떻게 될까?
# 나쁜 예 - 하나의 함수에 다 몰아넣기
@app.get("/users/{user_id}")
def get_user(user_id: int):
# 1. DB 연결
# 2. SQL 실행
# 3. 비즈니스 로직 (이메일 마스킹, 권한 체크 등)
# 4. 응답 변환
# 이 함수가 수백 줄이 된다
pass
이렇게 쓰면 문제가 많다:
- "DB 관련 코드가 어디 있지?" 찾기 어렵다
- 로직 하나 수정하면 다른 기능에 영향을 줄 수 있다
- 같은 로직이 여러 라우터에 중복된다
3계층으로 나누면:
# Presentation Layer - router.py
@router.get("/users/{user_id}")
def get_user(user_id: int, service: UserService = Depends(get_user_service)):
return service.get_user(user_id)
# 라우터는 짧고 단순하게 유지
# Business Layer - service.py
class UserService:
def get_user(self, user_id: int) -> User:
# 비즈니스 로직만 여기에
user = self.repository.find_by_id(user_id)
if user is None:
raise HTTPException(status_code=404, detail="유저 없음")
return user
# Data Access Layer - repository.py
class UserRepository:
def find_by_id(self, user_id: int) -> User | None:
# DB 접근만 여기에
pass
각 계층이 자신의 역할만 담당한다. DB 쿼리를 바꾸려면 Repository만, 비즈니스 로직을 바꾸려면 Service만 수정하면 된다.
계층 간 규칙
계층은 반드시 위에서 아래 방향으로만 호출한다.
Router → Service → Repository ✅ 올바른 방향
Repository → Service ❌ 하위가 상위를 호출하면 안 됨
Router → Repository ❌ 계층을 건너뛰면 안 됨
프로젝트 구조
3계층 아키텍처를 반영한 실제 프로젝트 구조다.
my_app/
├── main.py ← FastAPI 앱 생성, 라우터 등록
│
├── users/ ← 유저 도메인
│ ├── router.py ← Presentation Layer
│ ├── service.py ← Business Layer
│ ├── repository.py ← Data Access Layer
│ ├── models.py ← DB 테이블 대응 클래스
│ └── schemas.py ← 요청/응답 데이터 구조
│
└── database.py ← DB 연결 설정
models.py: DB 테이블과 1:1로 대응하는 클래스 (Spring의Entity)schemas.py: 요청/응답에 사용하는 Pydantic 모델 (Spring의DTO)
이 둘을 분리하는 이유는 DB 구조와 API 응답 구조가 달라질 수 있기 때문이다.
DDD란?
이 구조는 DDD(Domain-Driven Design, 도메인 주도 설계) 의 아이디어에서 왔다.
DDD의 핵심은 단순하다. 관련된 코드를 기능 단위(도메인)로 묶는다.
기능 단위가 아닌 역할 단위로 나누면 이렇게 된다:
app/
├── routers/
│ ├── user_router.py
│ └── order_router.py
├── services/
│ ├── user_service.py
│ └── order_service.py
└── models/
├── user.py
└── order.py
유저 관련 코드 하나를 수정하려면 routers/, services/, models/ 세 폴더를 왔다갔다 해야 한다.
도메인 단위로 나누면:
app/
├── users/
│ ├── router.py
│ ├── service.py
│ └── models.py
└── orders/
├── router.py
├── service.py
└── models.py
유저 관련 코드는 전부 users/ 폴더 안에 있다. 한 도메인을 수정할 때 다른 도메인을 건드릴 필요가 없다.
core/ 폴더
프로젝트가 커지면 여러 도메인에서 공통으로 쓰이는 코드가 생긴다. 이런 코드는 특정 도메인에 속하지 않으므로 core/ 폴더에 둔다.
app/
├── core/
│ ├── config.py ← 환경변수, 전역 설정
│ ├── database.py ← DB 연결, 세션
│ ├── exceptions.py ← 공통 예외 클래스
│ └── dependencies.py ← 공통 Depends 함수
├── users/
│ ├── router.py
│ ├── service.py
│ ├── repository.py
│ ├── models.py
│ └── schemas.py
└── auth/
├── router.py
├── jwt.py
├── hashing.py
└── dependencies.py
core/는 도메인이 아니라 인프라 레이어다. 도메인 폴더(users/, auth/)는 core/를 가져다 쓰지만, core/는 특정 도메인에 의존하지 않는다.
| 폴더 | 담는 것 | 예시 |
|---|---|---|
core/ | 전 도메인 공통 | DB 연결, 설정값, 공통 예외 |
users/ | 유저 도메인 | 유저 CRUD, 유저 모델 |
auth/ | 인증 도메인 | 로그인, JWT, bcrypt |
CQRS — 읽기와 쓰기 분리
DDD를 더 깊이 적용하면 CQRS(Command Query Responsibility Segregation) 패턴으로 이어진다.
핵심 아이디어는 하나다. 데이터를 바꾸는 작업(Command)과 데이터를 읽는 작업(Query)은 성격이 다르므로 분리한다.
Command (쓰기) → CREATE, UPDATE, DELETE → 상태를 변경한다
Query (읽기) → SELECT → 상태를 그대로 반환한다
일반적인 repository는 읽기와 쓰기를 한 파일에 섞는다:
# users/repository.py — 읽기/쓰기 혼재
class UserRepository:
def find_by_id(self, user_id: int) -> User: ... # 읽기
def find_all(self) -> list[User]: ... # 읽기
def save(self, user: User) -> User: ... # 쓰기
def delete(self, user_id: int) -> None: ... # 쓰기
CQRS를 적용하면 파일 자체를 나눈다:
# users/command_repository.py — 쓰기만
class UserCommandRepository:
def save(self, user: User) -> User: ...
def delete(self, user_id: int) -> None: ...
# users/query_repository.py — 읽기만
class UserQueryRepository:
def find_by_id(self, user_id: int) -> User: ...
def find_all(self, name_filter: str | None = None) -> list[User]: ...
폴더 구조로 보면:
app/
├── users/
│ ├── router.py
│ ├── service.py
│ ├── command_repository.py ← INSERT / UPDATE / DELETE
│ ├── query_repository.py ← SELECT
│ ├── models.py
│ └── schemas.py
왜 분리하는가
읽기와 쓰기는 요구사항이 다르게 진화한다.
- 쓰기는 트랜잭션, 유효성 검증, 비즈니스 규칙이 중요하다
- 읽기는 성능과 응답 형태 최적화가 중요하다
규모가 커지면 읽기 전용 DB(replica)를 따로 두거나, 읽기 모델을 캐시에 올리는 등의 최적화를 하게 된다. 이때 처음부터 분리되어 있으면 변경 범위가 좁다.
이 시리즈에서는 단순함을 위해 repository.py 하나로 진행하지만, 실제 서비스에서는 도메인 복잡도에 따라 CQRS 도입을 고려해볼 수 있다.
자동 문서 (Swagger UI)
FastAPI의 큰 장점 중 하나는 자동으로 API 문서를 만들어준다는 것이다.
서버를 실행한 뒤 http://localhost:8000/docs 에 접속하면 아래처럼 생긴 인터랙티브 문서가 나온다.
GET /users 유저 목록 조회
POST /users 유저 생성
GET /users/{id} 특정 유저 조회
DELETE /users/{id} 유저 삭제
각 API를 직접 클릭해서 요청을 보내볼 수 있다. 별도 Postman 없이 브라우저에서 바로 테스트할 수 있다.
정리
| 개념 | 설명 |
|---|---|
| FastAPI | Python HTTP API 프레임워크 |
| 데코레이터(@) | 함수의 역할을 FastAPI에게 알려주는 표시 |
| APIRouter | 경로를 파일별로 분리하는 도구 |
| 3계층 아키텍처 | Presentation / Business / Data Access로 분리 |
| Router | Presentation Layer. HTTP 요청/응답 담당 |
| Service | Business Layer. 비즈니스 로직 담당 |
| Repository | Data Access Layer. DB 접근 담당 |
Pydantic으로 데이터를 검증하고 의존성 주입을 사용하는 방법은 다음 편에서 다룬다.
시리즈: Python FastAPI 입문
- 시작하기 전에
- FastAPI 구조 - 라우터, 3계층 아키텍처 ← 현재 글
- Pydantic과 의존성 주입
- REST API 만들기
- SQLAlchemy와 데이터베이스 연동