기능 말고 도메인으로 폴더를 나눈 이유
새 백엔드 프로젝트를 열었을 때 제일 먼저 보는 건 코드가 아니라 폴더 트리다. 어떤 폴더가 어디에 있고, 무엇이 무엇 옆에 놓여 있는지. 나는 그 트리를 보면 이 프로젝트가 무슨 생각으로 짜였는지 대충 읽힌다고 본다. 폴더 구조는 단순한 정리 정돈이 아니라 이미 내려진 설계 결정이기 때문이다.
내가 쓰는 구조는 도메인 기반 모듈형이다. 기능 종류(model, schema, service…)로 폴더를 나누지 않고, 비즈니스 도메인(user, order, account 같은 것)으로 먼저 나눈다. 그리고 그 위에서 웹 서버(FastAPI)와 백그라운드 워커(Celery)의 관심사를 갈라놓는다.
한 도메인 안에 다 넣는다
app/domain/modules/ 아래에 도메인별 폴더가 있다. 예를 들어 order/ 같은 폴더 하나에 그 도메인의 모든 게 들어간다.
order/
order_model.py # SQLModel 테이블 정의
order_schema.py # API 요청/응답 스키마
order_repository.py # DB 접근 로직
order_router.py # FastAPI 라우터
order_service.py # 비즈니스 로직
order_tasks.py # Celery 백그라운드 작업
처음 보면 좀 의아할 수 있다. 보통 입문 튜토리얼은 models/ 폴더에 모든 모델, routers/ 폴더에 모든 라우터를 모아두라고 가르치니까. 그런데 그렇게 하면 주문 기능 하나를 고치려고 폴더 다섯 개를 왔다 갔다 하게 된다. 도메인으로 묶으면 “주문에 관한 모든 것”이 한자리에 있다. 기능을 추가할 때도 폴더 하나만 늘어난다.
규칙은 느슨하게 둔다. 파일 접두사가 폴더명과 꼭 같을 필요는 없다. 사용자 도메인 폴더가 member/인데 안의 파일들은 user_*로 시작해도 된다.
service는 도메인마다 꼭 두지는 않았다. 만드는 기준은 단순했다. 같은 로직이 두 군데 이상에서 필요할 때 만든다. 예를 들어 로그인·로그아웃은 관리자 쪽에도 일반 앱 쪽에도 필요한데, 그러면 같은 코드를 양쪽에 복붙하는 대신 service 하나로 합쳐서 둘이 같이 부른다. 트랜잭션을 여러 단계에 걸쳐 길게 이어줘야 할 때도 service로 묶었다. 반대로 한 군데서만 쓰고 끝나는 단순한 조회라면 굳이 만들지 않았다. 라우터나 태스크가 리포지토리를 직접 쓰면 된다. 그러다 한 service의 책임이 너무 커지면 cost_basis_service.py, external_trade_service.py처럼 목적별로 다시 쪼갰다.
공통은 따로 뺀다
도메인마다 반복되는 것들이 있다. 모델의 기반 클래스, 환경 변수 설정, 예외 정의, 공통 유틸리티 같은 것. 이걸 도메인마다 복붙하면 금방 어긋난다. 그래서 domain/common/ 하나로 모은다.
common/
base/ # BaseSqlModel, BaseSchema, BaseRepository
config/ # 환경 변수, 로깅 설정
exception/ # AppException과 HTTP 상태별 예외
schema/ # 공통 응답·검색 파라미터 스키마
util/ # 쿼리 빌더, 날짜/시간 유틸
base/에 깐 기반 클래스를 각 도메인 모델이 상속한다. 예외 형식도 common/exception/에서 한 번 정해두면 모든 도메인이 같은 모양으로 에러를 던진다. 도메인은 자기 일에만 집중하고, 공통의 결은 common이 잡아준다.
웹과 워커를 가른다
같은 도메인 코드를 두 종류의 실행 환경이 쓴다. 사용자 요청에 즉시 응답하는 웹 서버, 그리고 뒤에서 무거운 작업을 돌리는 워커. 둘은 비슷해 보여도 신경 쓸 게 다르다.
그래서 app/web/과 app/worker/를 따로 둔다. 각자 자기 진입점(web_app.py / celery_app.py)과 설정 폴더를 가진다. 차이가 드러나는 게 DB 세션 쪽이다. 워커는 프로세스를 포크해서 돌기 때문에 커넥션 풀을 그냥 공유하면 문제가 난다. 그래서 워커의 database.py는 NullPool을 써서 포크 안전성을 챙긴다. 웹 쪽은 Depends로 엔드포인트에 세션을 주입하고, 워커 쪽은 Task 안에서 세션을 안전하게 쓰는 별도 클래스를 둔다.
도메인 로직(service)은 한 벌만 쓰고, 그걸 웹의 라우터와 워커의 태스크가 각자 호출한다. 비즈니스 규칙은 한군데, 실행 환경 차이는 바깥에서 흡수하는 식이다.
트리만 봐도 읽힌다
이렇게 짜두면 test/도 app/ 구조를 그대로 미러링하면 된다. 도메인별로 테스트가 깔끔하게 갈린다.
결국 이 트리는 몇 가지 결정을 눈에 보이게 박아둔 것이다. 기능이 아니라 도메인으로 나눈다, 공통은 한 곳에 모은다, 웹과 워커는 갈라둔다. 새로 합류한 사람도 폴더만 훑으면 이 약속을 읽을 수 있다. 그래서 폴더 구조를 대충 잡으면 안 된다. 그게 곧 설계니까.