[WAFFLE] 03-1 Assignment 1 Review
Published:
In this post, review about assignment 1 will be provided.
Header Authentication and Dependency
과제1에서 아래와 같이 헤더를 통한 인증을 요구하는 스펙이 있었다. 
@user_router.get("/me", status_code=HTTP_200_OK)
def me(
x_wapang_username: Annotated[str, Header(...)],
x_wapang_password: Annotated[str, Header(...)],
user_service: Annotated[UserService, Depends()],
) -> MyProfileResponse:
user = user_service.get_user_by_username(x_wapang_username)
if not user or user.password != x_wapang_password:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED)
return MyProfileResponse.from_user(user)
- 위와 같이 코드를 작성하면 모든 인증이 필요한 API에서 관련 헤더를 매번 받아오고 검사하는 과정이 코드 상에서 불필요한 중복임. 만약 인증하는 방법이 바뀐다면 모든 곳에서 API 코드를 수정해야 함.
- 보통 코드에서 중복을 제거한다 함은 함수로 중복되는 코드를 분리하고 함수를 호출하는 것을 말하는데 이 경우에는 그렇게 하더라도 파라미터로 받아야하는 x_wapang_username, x_wapang_password는 여전히 중복해서 써야해 불필요한 중복이 발생함.
def login_with_header(
x_wapang_username: Annotated[str, Header(...)],
x_wapang_password: Annotated[str, Header(...)],
user_service: Annotated[UserService, Depends()],
) -> User:
user = user_service.get_user_by_username(x_wapang_username)
if not user or user.password != x_wapang_password:
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
return user
@user_router.get("/me", status_code=HTTP_200_OK)
def me(user: Annotated[User, Depends(login_with_header)]) -> MyProfileResponse:
return MyProfileResponse.from_user(user)
- 대신 위와 같이 login_with_header 함수를 만들고 여기다 x_wapang_username, x_wapang_password에 단순히 str 타입을 써놓는게 아니라 기존처럼 Header를 받을 수 있게 함.
- login_with_header를 종속성으로 등록하면 자동으로 login_with_header의 파라미터를 FastAPI가 검사를 하고 x_wapang_username과 x_wapang_password가 있네, 그리고 걔가 Header임을 명시했네, 그래서 유저의 요청으로부터 Header를 뽑아서 자동으로 함수의 인자로 전달해줌. 마찬가지로 user_service의 종속성을 만들어서 login_with_header의 인자로 넘겨줌.
Re-Usuable Validation
과제1에서 아래와 같이 회원가입 필드의 validation을 검증하는 스펙이 있었다. 
class UserSignupRequest(BaseModel):
username: str
email: str
password: str
@field_validator("username")
def validate_username(cls, username: str) -> str:
...
@field_validator("email")
def validate_email(cls, email: str) -> str:
...
@field_validator("password")
def validate_password(cls, password: str) -> str:
...
- pydantic에서 @field_validator 라는 데코레이터를 이용하여 특정 필드의 validation을 클래스의 인스턴스 생성 시에 자동으로 할 수 있다.
- 이 코드의 동작을 구체적으로 살펴보면
- 데코레이터 정의에 따라 위 코드는 validate_username = field_validator(“username”)(validate_username) 과 동일함을 알 수 있다. field_validator(“username”)은 Pydantic에서 제공하는 함수로, 특정 필드에 검증 로직을 연결하기 위한 데코레이터를 생성. 여기서 “username”은 validator가 적용될 Pydantic 모델의 필드를 지정함. field_validator는 데코레이터 객체(데코레이터 게시물에서 wrapper function + 알파의 개념)를 반환함. 이 데코레이터는 특정 함수(validator)를 감싸고, 해당 함수와 필드의 연결을 관리하는 역할을 함.
- 위에서 생성된 데코레이터에 따라 코드는 validate_username = decorator(validate_username) 의 형태가 되고 Pydantic이 validate_username 함수를 “username” 필드의 검증 로직으로 등록한다.
- 전달된 validate_username 함수가 “username” 필드와 연결되면, Pydantic 모델은 이 연결 정보를 젖아하여, 나중에 “username” 필드 값이 설정되거나 초기화 될 때 이 검증 함수가 호출되도록 만든다.
- 더 큰 흐름에서 이 코드는
- FastAPI에서 POST 요청으로 데이터를 받고, 이를 Pydantic 모델로 처리. 클라이언트가 /signup 엔드포인트로 JSON 데이터를 보내면 FastAPI는 요청 데이터를 읽고, 이를 Pydantic 모델인 UserSignupRequest로 변환하려 시도함. 요청 데이터를 UserSignupRequest의 필드 username, email, password와 매핑함.
- 매핑이 완료되면 필드별로 정의된 @field_validator가 실행됨. username을 예시로 들면,
- validate_username(cls, username) 메서드가 호출됨.
- username 값 “hoseok”이 밸리데이터 로직에 전달됨.
- 반환된 값이 username 필드에 다시 저장됨.
- 만약 밸리데이터가 예외를 발생시키면 요청은 즉시 중단되고 FastAPI가 에러 응답을 반환함. 모든 밸리데이터가 통과되면, UserSignupRequest 인스턴스가 생성됨.
- 유효성 검사를 통과한 데이터를 signup 함수의 user 매개변수로 전달 됨.
- 위 코드의 문제는, 이메일을 검증하는 로직을 validate_email 함수 안에 구현했는데 이런 모델이 여러 개라면 그 때마다 validate_email 함수를 구현해줘야 함.
def validate_username(username: str) -> str:
...
def validate_email(email: str) -> str:
...
def validate_password(password: str) -> str:
...
class UserSignupRequest(BaseModel):
username: str
email: str
password: str
_validate_username = field_validator("username")(validate_username)
_validate_email = field_validator("email")(validate_email)
_validate_password = field_validator("password")(validate_password)
- 이를 해결하기 위한 첫 번째 방법은 위처럼 데코레이터를 함수처럼 호출하는 것이다. 그러나 코드적으로 헷갈리므로 권장되지 않음.
- 위 코드에서 그냥 field_validator(“username”)(validate_username) 로 쓰지 않고 이를 _validate_username 에 할당하는 이유가 궁금했다. Pydantic은 모델 초기화 시 클래스의 모든 속성(필드와 메서드)을 검사한다. field_validator 데코레이터가 생성한 검증 로직은 클래스의 속성으로 할당되어야만 Pydantic이 이를 인식하고, 해당 필드에 대한 검증 로직으로 등록한다.
def validate_username(username: str) -> str:
...
def validate_email(email: str) -> str:
...
def validate_password(password: str) -> str:
...
class UserSignupRequest(BaseModel):
username: Annotated[str, AfterValidator(validate_username)]
email: Annotated[str, AfterValidator(validate_email)]
password: Annotated[str, AfterValidator(validate_password)]
- 권장되는 방식은 위와 같이 Annotated Validator를 사용하는 것이다.
- 위 코드를 해석하면 username은 기본적으로 str 타입인데, 거기에 추가적으로 AfterValidator 라는 메타데이터를 추가하는 것이다. Pydantic이 자동으로 각 필드의 메타데이터를 살펴본 다음, 거기에 validator 관련된 메타데이터가 있으면 그것들을 뽑아다 미리 저장해둠. 저장을 해놓고 인스턴스를 생성할 때 걔네를 이용해 유효성 검증을 함.
- 검증 시점에 따랄 인스턴스가 생서되기 전이면 BeforeValidator, Pydantic의 기본 validation을 거친 이후면 AfterValidator, 앞 뒤로 감싸고 싶으면 WrapValidator를 사용하면 됨.
Define Custom Error
class EmailAlreadyExistsError(HTTPException):
def __init__(self) -> None:
super().__init__(HTTP_409_CONFLICT, "Email already exists")
class UsernameAlreadyExistsError(HTTPException):
def __init__(self) -> None:
super().__init__(HTTP_409_CONFLICT, "Username already exists")
- HTTPException을 그때 그때 인스턴스화 해서 그 안에 status_code 와 에러 메시지를 넣는 방식은 비효율적.
- 대신 위처럼 커스텀 에러를 정의해서 사용하는 것이 편함.
class WapangBaseError(HTTPException):
def __init__(self,
status_code: int,
message: str,
error_code: WapangErrorCode) -> None:
# 에러를 구체화시키는 코드
class EmailAlreadyExistsError(WapangBaseError):
def __init__(self) -> None:
super().__init__(HTTP_409_CONFLICT, "Email already exists", ...)
class UsernameAlreadyExistsError(WapangBaseError):
def __init__(self) -> None:
super().__init__(HTTP_409_CONFLICT, "Username already exists", ...)
- 필요에 따라서는 단순히 HTTPException을 상속만 받는 게 아니라, HTTPException을 상속 받는 BaseError를 만들고, 그 BaseError를 상속받는 식으로 구현할 수도 있음.
class WapangBaseError(Exception):
status_code: int
detail: str
def __init__(self, status_code: int, detail: str):
...
@exception_handler(WapangBaseError)
def handle_wapang_base_error(request: Request, exc: WapangBaseError):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
- HTTPException을 상속 받든, 안받든, exception_handler를 통해 특정 타입의 에러에 대한 핸들러를 등록할 수 있다.
- 만약 특정 종류의 에러에 대해서만 특정 종류의 에러 핸들러를 구축하고 싶다면 그 에러들을 아우르는 부모 클래스를 만들고 그 자식 클래스들로서 각각의 에러를 구현해주게 되면 이 에러들은 커스텀 핸들러를 통해 핸들링을 할 수 있게 되고 나머지 HTTPException 들은 FastAPI의 기본 핸들러를 통해 핸들링 할 수 있다.
UserStore
class UserStore:
def __init__(self) -> None:
self.id_counter = 0
self.store: dict[int, User] = {}
self.username_index: dict[str, int] = {}
self.email_index: dict[str, int] = {}
... # 필요한 메서드들 정의
- UserStore 라는 별도의 클래스를 만들어 실질적으로 데이터를 담아야 하는 store 변수, username_index, email_index 등을 만듦.
- 과제 1을 수행하며 user_db = {} 처럼 전역 변수로 데이터를 저장하는 인메모리 변수를 만들어두고 코드를 작성함. 그러나 그렇게 전역변수를 남발하는 게 결코 좋지 않음.
- 유지보수가 용이한 코드베이스를 유지하기 위해, OOP에 익숙해지는 것이 좋음
- 인메모리 저장소가 필요하므로, 관련 로직들을 UserStore 클래스로 분리
- 여러 자료구조를 하나의 클래스로 묶을 수 있음
- 다른 저장소를 사용하게 되더라도(인메모리 대신 db), 인터페이스를 공유하도록 설계해서 쉽게 교체 가능
class UserStore:
...
@cache
def get_user_store() -> UserStore:
return UserStore()
- UserStore 는 인메모리 db 이므로 딱 한 번만 생성되어야 함. 여러번 생성되면 각각의 인스턴스가 서로 다른 데이터를 저장하고 있어 데이터의 파편화가 발생하게 됨.
- FastAPI는 매 요청마다 종속성으로 등록한 Callable을 호출함. 그렇기 때문에 UserStore를 그대로 종속성으로 등록하면 매 요청마다 새로운 인스턴스가 생성됨. @cache를 사용하여 UserStore 인스턴스를 캐시하면, 최초 요청에서만 생성되고 이후에는 캐싱된 인스턴스가 반환됨.
- 클래스에는 @cache 데코레이터를 사용할 수 없으므로, 별도의 함수를 정의하고 캐시 적용.
- new 메서드를 오버라이딩하거나 metaclass를 이용하는 방법도 있으니 공부!
Design Pattern
디자인 패턴이란 문제를 해결하기 위한 좋은 설계를 위한 방법론
- 특정한 프로그래밍 언어나 프레임워크에 종속적이지 않음
- 개발자들 사이에 공통된 언어를 제공함
- 개발자들이 소프트웨어를 이해하고, 소프트웨어를 설계하는 데 도움을 줌
Layered Architecture

- 코드가 복잡해짐에 따라, 관심사의 분리가 필요해짐. 코드를 수정해야 할 때 어디부터 건드려야 할지 난감하거나, 특정 feature를 개발할 때 손을 댈 곳이 많다면 잘 못 짜여진 코드.
- 시스템을 분리하는 방법은 다양하지만, Layered Architecture가 사실상 표준
- 다른 방법론도 많이 있지만, 대개 LayeredArchitecure를 기반으로 함.
- 과제 1의 경우 최상단 Presentation Layer의 views가 엔드포인트를 정의.
- 그 아래 UserService가 Business logic을 처리함. 과제 1에서는 business logic이라 할 만한 게 없어서 그 layer가 굉장히 thin함.
- Persistence Layer는 Business Layer와 Database Layer를 연결해줌. 과제 1에서는 인메모리 저장소를 이용하기 때문에 사실상 Persistence Layer가 Database Layer의 역할을 거의 그대로 하고 있음. 과제 2에서는 명확히 두 레이어가 구분됨.

- Layered Architecture를 이용하여 디렉터리 구조를 짠다면 위와 같이 구성할 수 있음.
- 그럴듯해 보이지만, user, item과 같은 도메인이 하나씩 늘어나 30개가 된다면 views안에 30개의 views 파일이 있는 문제가 생김.

- 규모가 커지면 Layered Architecture와 더불어 도메인 별로 쪼개는 방식을 채택. 먼저 도메인(user, item)으로 디렉터리를 나누고 그 안에 개별적으로 Layered Architecure 적용.
Domain-Driven Design

- 더 나아가 더 큰 프로젝트를 설계해야 한다면 많은 개발자들의 필독서로 거론되는 것 중 하나가 Eric Evans의 Domain-Driven Design이다.
- 도메인 주도 개발이란, 복잡한 현실의 문제를 코드상으로 반영하는 도메인 모델을 설계하고, 이를 기반으로 소프트웨어를 개발하는 방법론.

Leave a Comment