프로젝트 재구성!
요놈은 2022년 7월 10일에 시작했던 프로젝트라서 그런지, 이번에 새로이 다시 시작하고자 그동안 빌드했던 것들을 보니.. 여러모로 바꿔야 할 것들이 완전 산더미였다.
게다가, 생각보다 진행된 것들도 많이 없었고 (사실 아예 없다고 보는게 맞을지도 모른다.) 기능 구현은 커녕 기본적인 구조조차 영 맘에 들지 않아서 한 2일정도 고민하다가 아예 새로 밀어버리고 시작하자 라는 결론이 내려졌다.
물론 그렇다고 해서 레포 자체를 밀어버리자니 정든 친구를 한방에 보내버리는 느낌이 들어서, 기존 레포에 설정되어 있던 이슈 네이밍 방식이나, PR 구성 등과 같은 레포의 기존 틀은 그대로 유지한 채로 새로운 버전 (JUV v2) 을 진행했다.
2022년 7월... 쯤 도입했던 폴더 구조는 아래와 같았다.
├── Server
│ └── app
│ ├── business # 내부 비즈니스 로직에서 사용하려고 했던... 것 같다.
│ │ ├── db
│ │ │ ├── DbAbstract.py
│ │ │ └── login
│ │ │ └── UserLogin.py
│ │ └── router # 라우터가 비즈니스 로직 안에 있다(?)
│ │ └── webtoon
│ │ └── GetWebToon.py
│ ├── core # Config 파일들을 전부 모아놓는 곳
│ │ ├── ConfigAbstract.py # Type Hint를 주기 위해 사용했다.
│ │ ├── compact # dataclass로 하나로 전부 합치려고 했었다.
│ │ │ └── CompactConfig.py
│ │ └── configs # 모든 Config들을 dataclass에 상수로 박아서 사용했었다.
│ │ ├── BaseConfig.py
│ │ ├── ClovaConfig.py
│ │ ├── DBConfig.py
│ │ ├── SlackConfig.py
│ │ ├── SlackTemplates.py
│ │ ├── TeamsConfig.py
│ │ ├── TestConfig.py
│ │ └── UserConfig.py
│ ├── infra # 외부... 에서 끌어오는 것들을 다 넣으려고 했던 것 같다.
│ │ ├── ai
│ │ │ ├── AiAbstract.py
│ │ │ └── clova
│ │ │ └── ClovaRequest.py
│ │ ├── alarm
│ │ │ ├── AlarmAbstract.py
│ │ │ ├── slack
│ │ │ │ └── SlackAlarm.py
│ │ │ └── teams
│ │ │ └── TeamsAlarm.py
│ │ ├── customRequest # Request도 전부 하나로 추상화해서 통일하고자 했었던 흔적
│ │ │ ├── RequestAbstract.py
│ │ │ ├── RequestDTO.py
│ │ │ ├── httpx
│ │ │ │ └── httpxModule.py
│ │ │ └── pythonRequests
│ │ │ └── requestsModule.py
│ │ └── db
│ │ ├── DBAbstract.py
│ │ ├── mariaDB
│ │ │ ├── MariaDB.py
│ │ │ ├── MariaDBConnector.py
│ │ │ └── MariaDBDecorator.py
│ │ └── sqlAlchemy
│ │ ├── SqlAlchemy.py
│ │ └── SqlAlchemyDecorator.py
│ ├── main.py
│ ├── remarkableTemp # 기억하고 싶은 코드를 여기다가 넣어놓은 것 같은데... 어째서?...
│ │ ├── UserAbstract.py
│ │ ├── UserDTO.py
│ │ └── UserEncryption.py
│ ├── runner # Main 실행 함수를 이쪽으로 뺐었다.
│ │ ├── LoadDelay.py
│ │ └── MainRunner.py
│ └── test
│ ├── RunnerAbstract.py
│ ├── caseList # 테스트 코드를 dataclass에 넣어서 한번에 케이스별로 나눠서 돌리려고 했다.
│ │ ├── TestCaseAbstract.py
│ │ ├── TestCaseList.py
│ │ ├── clova
│ │ │ └── ClovaRequestTest.py
│ │ ├── mariaDB
│ │ │ └── MariaDBQueryTest.py
│ │ ├── slack
│ │ │ └── SlackAlarmTest.py
│ │ └── sqlAlchemy
│ │ └── SqlAlchemyQueryTest.py
│ └── compactRunner # 이 녀석이 한번에 돌려줬었다.
│ └── CompactRunner.py
지금와서 폴더 구조를 보니, 무엇을 하려고 했었는지는 어렴풋이 느껴지는데... 지금 이 프로젝트를 저런 구조로 가서는 안되겠다는 생각이 들었다.
사실, 그냥 전에 구조가 맘에 안든다... 💫
뚱땅뚱땅 두들겨서 새로이 만든 구조는 이렇다.
├── env # 배포 환경에서 사용할 설정 모음!
│ ├── base
│ │ └── task.sh
│ └── docker
│ └── docker-compose.yml
├── src
│ ├── api # Controller를 모아놓는 폴더
│ │ ├── constructor.py
│ │ └── user
│ │ └── guest
│ │ └── guestApi.py
│ ├── application # 비즈니스 로직 구현체(도메인 구현체)를 모아놓는 폴더
│ │ └── user
│ │ └── guest
│ │ └── guestService.py
│ ├── container # python에서 DI를 사용하기 위해 dependency-injector 라이브러리를 구성했다.
│ │ ├── app
│ │ │ └── appInitializeContainer.py
│ │ └── containerconstructor.py
│ ├── core # 설정 파일 모음 (toml 파일로 진행했다.)
│ │ ├── app
│ │ │ └── appConfig.toml
│ │ ├── db
│ │ │ ├── mariaConfig.toml
│ │ │ ├── mongoConfig.toml
│ │ │ └── redisConfig.toml
│ │ ├── secure
│ │ │ └── secureConfig.toml
│ │ └── weather
│ │ └── weatherConfig.toml
│ ├── domain # 추상화 객체들을 모아놓는 폴더
│ │ ├── board
│ │ │ ├── repo # 개인적인 경험에, repo와 service를 분리하는게 편했다. 쿼리 분리하기도 좋고...
│ │ │ │ └── boardRepo.py
│ │ │ └── service
│ │ │ └── boardService.py
│ │ ├── noti
│ │ │ ├── repo
│ │ │ │ └── notiRepo.py
│ │ │ └── service
│ │ │ └── notiService.py
│ │ ├── pay
│ │ │ ├── goods
│ │ │ │ ├── repo
│ │ │ │ │ └── goodsRepo.py
│ │ │ │ └── service
│ │ │ │ └── goodsService.py
│ │ │ └── subscribe
│ │ │ ├── repo
│ │ │ │ └── subscribeRepo.py
│ │ │ └── service
│ │ │ └── subscribeService.py
│ │ ├── report
│ │ │ ├── repo
│ │ │ │ └── reportRepo.py
│ │ │ └── service
│ │ │ └── reportService.py
│ │ ├── secure
│ │ │ ├── repo
│ │ │ │ └── secureRepo.py
│ │ │ └── service
│ │ │ └── secureService.py
│ │ ├── user
│ │ │ ├── admin
│ │ │ │ ├── repo
│ │ │ │ │ └── adminRepo.py
│ │ │ │ └── service
│ │ │ │ └── adminService.py
│ │ │ ├── author
│ │ │ │ ├── repo
│ │ │ │ │ └── authorRepo.py
│ │ │ │ └── service
│ │ │ │ └── authorService.py
│ │ │ ├── guest
│ │ │ │ ├── repo
│ │ │ │ │ └── guestRepo.py
│ │ │ │ └── service
│ │ │ │ └── guestService.py
│ │ │ └── reader
│ │ │ ├── repo
│ │ │ │ └── readerRepo.py
│ │ │ └── service
│ │ │ └── readerService.py
│ │ ├── weather
│ │ │ ├── repo
│ │ │ │ └── weatherRepo.py
│ │ │ └── service
│ │ │ └── weatherService.py
│ │ └── webtoon
│ │ ├── repo
│ │ │ └── webtoonRepo.py
│ │ └── service
│ │ └── webtoonService.py
│ ├── infra # 외부 라이브러리에 의존성이 높은 친구들은 구현체를 이쪽으로 빼줬다.
│ │ ├── maria
│ │ │ ├── mariaCore.py
│ │ │ ├── mariaSchema.py
│ │ │ └── user
│ │ │ └── guest
│ │ │ └── guestRepo.py
│ │ ├── mongo
│ │ │ ├── mongoCore.py
│ │ │ └── mongoSchema.py
│ │ └── redis
│ │ ├── redisCore.py
│ │ └── redisSchema.py
│ ├── main.py
│ └── tool # 실행, 환경구성 등 잡다한 일을 하는 녀석들은 모두 여기로...
│ └── app
│ ├── app.py
│ ├── appArgParser.py
│ └── appCreator.py
위 폴더 구조는 최근에 친구들과 함께 스터디를 진행하며 빌드했던 폴더 구조를 거의 그대로 차용한 구조이다.
처음에는 새로운 구조를 도입해볼까 고민했었는데, 위 구조가 자그마한 실무에 도입했을 때도 만족도가 높았고, 스터디를 진행하면서도 꽤나 만족스러웠던 기억이 있기에 일단 저대로 한번 진행해보기로 결정했다.
🤔 묘하게도... 공부의 깊이가 부족했던 것인지, 해당 구조에 대한 만족스러웠던 기억들이 대부분이라 반대로 위 구조가 가진 문제나 불편한 점을 알려면 계속 사용해보며 몸소 체험해봐야 하지 않을까... 라는 생각이...
폴더 구조를 잡고 나니 이제 몇가지 기본적인 로직들을 작성해야 했다. 그 중에서도 간단한 로직들에 대한 설명은 제외하고... 이번에 새롭게 시도해본 로직에 관해서만 간단하게 적어본다.
FastAPI의 include_router() 를 편하게 할 수 없나...
FastAPI를 사용하면서 제일 아쉬웠던 점 중 하나가 바로 ApiRouter 객체를 메인 FastAPI 객체에 직접 include_router()를 통해 등록해줘야 한다는 것이었다.
각 Controller들을 편하게 관리하고자 ApiRouter 객체를 도메인을 기준으로 별도로 생성하여 관리하게 되면, 각 도메인에 존재하는 ApiRouter를 메인 FastAPI에 등록해야 하는데, 이 과정과 코드가 뭔가 맘에 안들었다. 한곳에서 등록하게 된다면 등록된 path들을 한번에 파악할 수 있을테고, prefix가 수정되더라도 해당 Controller 코드에 직접 접근해서 수정하지 않아도 되지 않을까... 라는 개인적인 생각이 들었기 때문이다.
일단 직접 만들어보기 전에, 분명 같은 고민을 한 사람들이 있을 것 같아 한번 쭉 검색을 해보았는데... 비슷한 고민을 한 사람들은 있으나 결국 기존 방식 그대로 구현한 것 같았다.
mount()를 이용해서 좀 더 다이나믹하게 구성하면 달라지나...
이런저런 생각이 머리를 스쳐 지나갔다. 하지만, 일단 현재 가지고 있는 정보에 한해서는 해당 기능을 구현한 케이스가 없었기 때문에, 간단하게라도 일단 직접 만들어서 사용해보자는 결론에 이르렀다.
구현하게 된 코드는 아래와 같다.
해당 구현에 많은 도움을 준 회사 동료 김X얼과 이X수... 감사해여 :)
@classmethod
def _gen_injection_map(cls, router_class: dataclass) -> Dict[str, List[APIRouter]]:
injection_map: Dict[str, List[APIRouter]] = {}
def _split_router(routes_class: dataclass, parent_prefix: str = ""):
current_prefix: str = parent_prefix + routes_class.prefix
target_routers = injection_map.get(current_prefix, [])
for k, v in vars(routes_class).items():
if is_dataclass(v):
_split_router(routes_class=v, parent_prefix=current_prefix)
elif isinstance(v, APIRouter):
target_routers.append(v)
injection_map[current_prefix] = target_routers
_split_router(routes_class=router_class)
return injection_map
@classmethod
# 추후 여러개의 app를 생성해서 app끼리 app.mount를 통해 빌드할 수도 있다는 판단에 List[] 형태로 요구!!!
def _route_injection(cls, injection_maps: List[Dict[str, List[APIRouter]]], apps: List[FastAPI]):
if len(injection_maps) != len(apps):
raise Exception("Args Length must be Same")
[
target_app.include_router(router, prefix=prefix) for
injection_map, target_app in zip(injection_maps, apps) for
prefix, routers in injection_map.items() for
router in routers
]
모든 개발자들이 그렇겠지만, 완벽하게 마음에 드는 코드는 절대 아니다. 하지만 매일매일 고민하며 계속해서 맘에 들게 고치다 보면 무한한 리팩토링의 늪에 빠져 생산성을 잃을 것만 같기에... 일단은 현재의 내가 생각하는 최선을 적어내는 수 밖에 없다.
사용법은 간단하다. ApiRouter 객체들을 포함한 dataclass를 FastAPI 객체와 함께 넣어주면 된다. 그럼 dataclass안의 정보들을 쓸어 FastAPI 객체에 등록시켜준다. dataclass의 형태를 간단히 작성하면 아래와 같은 형태다.
from dataclasses import dataclass
from typing import Optional
from fastapi import APIRouter
@dataclass(frozen=True)
class Routers:
prefix: Optional[str] = ""
auth_routers: APIRouter = ""
login_routers: APIRouter = ""
@dataclass(frozen=True)
class User:
prefix: Optional[str] = "/user"
board_routers: APIRouter = ""
@dataclass(frozen=True)
class Alarm:
prefix: Optional[str] = "/alarm"
slack_routers: APIRouter = ""
tail: Alarm = Alarm()
@dataclass(frozen=True)
class Buy:
prefix: Optional[str] = "/buy"
product_routers: APIRouter = ""
@dataclass(frozen=True)
class Cart:
prefix: Optional[str] = "/cart"
cart_routers: APIRouter = ""
tail: Cart = Cart()
tail_first: User = User()
tail_second: Buy = Buy()
제약사항이 있다면, prefix 필드를 필수로 가져가야 한다는 점이다. dataclass 내의 prefix 필드를 찾고 해당 필드 값을 다음 depth의 parent prefix로 쌓아나가기 때문이다.
이게 과연 실제로 써봐서 편할지는... 🧐
지금 당장은 include_router() 함수를 통해 ApiRouter를 등록하는게 전부이지만, 나중에는 mount() 함수를 통해 각기 다르게 형성된 FastAPI를 묶어주는 기능도 필요할 것 같다. 사실, 당장은 이게 편한지 좋은지도 정확히 결론내릴 수 없기 때문에... 일단 프로젝트를 진행해나가며 스스로 피드백 해봐야겠다.
개인 프로젝트의 첫 발걸음을 때는 이번 PR은 정말 두근두근 그 자체였다.
갈 길은 멀지만, 모든 음식의 첫 입이 가장 맛있듯이, 이번 프로젝트도 첫 걸음이 가장 두근두근 하지 않나 싶다.
가보자고!