스프링 프레임워크로 공부를 시작한 개발자로서 다른 프레임워크를 사용할 때에도 SOLID 원칙을 지키는 것을 선호합니다. FastAPI를 처음 접했을 때, "자유롭게 코드를 작성할 수 있는 프레임워크"라기보다는 "자유롭기 때문에 더 엄격한 규칙이 필요한 프레임워크"라고 느꼈습니다.
FastAPI는 빠르게 기능을 구현하기에 좋은 프레임워크입니다. 라우터에 비즈니스 로직부터 DB 조회까지 모두 넣어도 동작하는 데 문제가 없습니다. 하지만 이러한 구조는 시간이 지나면서 큰 기술 부채가 됩니다.
일정 수준 이상의 규모로 코드베이스가 성장하면, 코드는 작동하지만 동작 원리를 이해하기 어려운 상태가 됩니다.
이 글에서는 FastAPI에서 SOLID 원칙을 실전적으로 적용하기 위해, 레이어드 아키텍처를 어떤 기준으로 나누고 어떻게 연결할지를 정리하였습니다.
레이어드 아키텍처는 가장 단순한 형태의 아키텍처로, 기능을 경계로 소스 코드를 분리합니다. 명확한 경계는 개발 생산성을 높여줍니다.
FastAPI는 구조를 강제하지 않기 때문에, 경계를 설계하지 않으면 개발자마다 다른 경계를 만들게 됩니다. 경계를 잘 나누는 것이 핵심이며, 이때 SOLID 원칙이 유용하게 활용됩니다.
대부분의 웹 백엔드 프레임워크는 기본적으로 4계층으로 나뉩니다.
핵심: 중요한 것은 "몇 개로 나누는가"가 아니라 "무엇을 경계로 나누는가"입니다.
의존성은 위에서 아래로만 흐릅니다.
이 원칙이 지켜지지 않으면 테스트에서 문제가 발생합니다.
예시:
위와 같은 상황은 경계가 깨졌다는 신호입니다.
디렉토리 구조는 규칙을 강제하는 가장 쉬운 장치입니다. 다음 구조는 도메인 단위 확장과 레이어 단위 책임 분리를 동시에 만족합니다.
app/
├── api/
│ └── v1/
│ └── {domain}/
│ ├── router.py # 엔드포인트 정의
│ ├── schemas.py # Pydantic 요청/응답 모델
│ └── dependencies.py # DI 팩토리 함수
├── service/
│ └── {domain}/
│ ├── service.py # 비즈니스 로직 클래스
│ ├── dto.py # 내부 데이터 전송 객체 (필요시)
│ └── exceptions.py # 도메인 예외
├── repository/
│ └── {domain}/
│ ├── repository.py # 데이터 접근 클래스
│ └── models.py # SQLAlchemy ORM 모델
└── core/
├── database.py # 세션/트랜잭션 공통
└── config.py # 설정
이 구조의 가장 큰 장점은 "파일을 어디에 만들지" 고민하는 시간을 없애준다는 점입니다. 팀 규모가 커질수록 이 효과는 더욱 두드러집니다.
요청 흐름을 단순히 "Router → Service → Repository"로만 기억하면, 시간이 지나면서 다시 섞이게 됩니다. 더 나은 접근 방식은 "어떤 타입이 어느 경계를 통과하는가"를 기준으로 이해하는 것입니다.
SOLID 원칙은 이론보다 "무엇을 어디에 두지 않을지"를 결정할 때 진가를 발휘합니다.
SRP를 계층에 매핑하면 명확해집니다.
파일을 열었을 때 "이 수정이 왜 여기서 일어나지?"라는 질문이 생긴다면, 그 코드는 이미 다른 계층의 책임을 침범한 것입니다.
로깅, 트레이싱, 메트릭, 트랜잭션, 재시도 같은 기능은 거의 항상 여러 기능에 걸쳐 반복됩니다. 이를 각 함수에 직접 넣기 시작하면 확장이 곧 수정이 됩니다.
해결책: Service 메서드에 데코레이터를 적용합니다. 데코레이터는 OCP를 가장 쉽게 체감할 수 있는 방법입니다.
Service 계층이 가장 적절합니다.
DIP의 목적은 고수준 로직이 저수준 디테일에 끌려다니지 않게 하는 것입니다.
Router에서 전역 import로 Repository를 직접 사용하는 경우:
# ❌ 나쁜 예
from repository.user import UserRepository
@router.get("/users/{id}")
def get_user(id: int):
repo = UserRepository()
return repo.get_by_id(id)
이는 단순히 코드가 깔끔하지 않은 문제가 아니라, 테스트 가능성과 변경 가능성을 동시에 해칩니다.
Router는 Service만 주입받고, Service가 Repository를 사용합니다.
# ✅ 좋은 예
@router.get("/users/{id}")
def get_user(
id: int,
service: UserService = Depends(get_user_service)
):
return service.get_user(id)
장점:
DIP는 "테스트에서 무엇을 어디까지 가짜로 바꿀 수 있는가"로 체감됩니다.
함수형 Repository를 사용하면 파라미터로 db/session이 계속 전달됩니다. 이는 단순히 번거로운 것이 아니라 호출 경계가 넓어지는 문제입니다.
클래스 기반 Repository가 권장되는 이유:
patch.object로 메서드만 교체하면 됩니다가장 큰 장점은 확장 방식이 열린다는 점입니다. 상속이나 데코레이터로 공통 기능(페이징, 공통 필터, 소프트 삭제 조건)을 Repository 레벨에서 일관되게 적용할 수 있습니다.
Repository는 단순 CRUD가 아니라 "조회 전략"을 담습니다.
1. 비즈니스 로직을 넣지 않습니다
쿼리 최적화나 인덱스 변경이 비즈니스에 영향을 주지 않도록 합니다.
2. 단일 엔티티 중심으로 분리합니다
메서드 의미가 명확해지고, 변경 시 범위가 작아집니다.
3. 메서드 이름은 질의 의도를 드러냅니다
# 좋은 예
get_by_id(id: int)
get_by_email(email: str)
list_active()
이러한 이름은 그 자체로 계약(contract)입니다.
Service는 "흐름의 오케스트레이션"입니다.
Service가 HTTPException을 던지기 시작하면, Service는 이미 HTTP를 알고 있는 것입니다. 이는 재사용성을 크게 떨어뜨립니다.
# ❌ 나쁜 예
class UserService:
def get_user(self, id: int):
user = self.repo.get_by_id(id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
동일 서비스를 CLI나 배치에서 사용하고 싶어도 HTTP를 끌고 다니게 됩니다.
# ✅ 좋은 예
# exceptions.py
class UserNotFoundError(Exception):
pass
# service.py
class UserService:
def get_user(self, id: int):
user = self.repo.get_by_id(id)
if not user:
raise UserNotFoundError(f"User {id} not found")
return user
# router.py
@router.get("/users/{id}")
def get_user(id: int, service: UserService = Depends()):
try:
return service.get_user(id)
except UserNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
이것은 단순한 취향 문제가 아니라 경계를 지키는 설계입니다.
트랜잭션의 시작 지점이 Router로 올라가면 HTTP 수명주기와 데이터 일관성이 엉키기 쉽습니다. 또한 Router는 도메인 흐름을 알지 못해 트랜잭션 범위를 적절히 설정하기 어렵습니다.
Service 메서드에 트랜잭션 데코레이터를 적용합니다.
장점:
Router의 역할을 "얇게" 유지하려면 DI 설계가 중요합니다. dependencies.py가 사실상 "구성(composition)" 레이어 역할을 합니다.
도메인별 dependencies.py에 팩토리 함수를 두는 이유:
dependency_overrides로 교체가 쉬워집니다특히 대규모 프로젝트에서 Router 파일들이 "얇아지는" 효과는 협업 속도에 직접적인 영향을 줍니다.
레이어드 아키텍처에서는 테스트도 같은 철학을 따릅니다. "무엇을 검증하고, 무엇을 가짜로 둘 것인가"가 경계에 의해 결정됩니다.
Repository 테스트에서 검증할 것은 "비즈니스"가 아니라 "쿼리"입니다.
검증 항목:
execute가 올바르게 호출되는가?scalar_one_or_none 등으로 의도대로 추출하는가?flush/commit이 필요한 지점이 올바른가?AsyncMock과 MagicMock으로 세션을 모킹하면 DB 없이도 이 레이어의 계약을 검증할 수 있습니다.
Service 테스트는 Repository를 신뢰하지 않습니다. Repository 메서드를 patch.object로 교체하여 Service의 규칙만 테스트합니다.
검증 항목:
UserNotFoundError를 던지는가?update_data가 올바르게 구성되는가?이 레벨의 테스트가 탄탄하면 리팩토링할 때 자신감이 생깁니다.
Router 테스트는 가장 바깥쪽 경계 테스트입니다.
검증 항목:
FastAPI의 dependency_overrides는 이 레벨에서 강력합니다. Service를 통째로 대체하여 Router가 HTTP 계약을 잘 지키는지에만 집중할 수 있습니다.
실전에서 흔한 실수는 대부분 "경계 침범" 하나로 설명됩니다.
| 안티패턴 | 문제 | 원인 |
|---|---|---|
| Router에서 DB 쿼리 직접 실행 | 테스트가 DB에 의존 | Router가 영속성 경계를 침범 |
| Service에서 HTTPException 발생 | 재사용성 저하 | Service가 HTTP 경계를 침범 |
| Repository에 비즈니스 로직 포함 | 책임 불명확 | Repository가 도메인 경계를 침범 |
| 전역 import로 Service 사용 | 테스트 교체 불가 | DIP 위반으로 테스트 가능성 상실 |
이는 단순히 코드가 엉키는 문제가 아니라 도메인 경계가 애매해졌다는 신호입니다. 특정 흐름에서 필요한 정보는 Repository 레벨에서 직접 조회해 해결하는 것이 더 안전한 경우가 많습니다.
FastAPI는 "간단해서" 성공하는 프레임워크가 아니라, "간단하지만 깊게 설계할 수 있어서" 오래 살아남는 프레임워크입니다. 그 설계를 가능하게 해주는 가장 현실적인 출발점이 SOLID 기반 레이어드 아키텍처입니다.
이 패턴을 따르면: