[WAFFLE] 03-1 Assignment 1 Review

7 minute read

Published:

In this post, review about assignment 1 will be provided.

Header Authentication and Dependency

과제1에서 아래와 같이 헤더를 통한 인증을 요구하는 스펙이 있었다. 과제1_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을 검증하는 스펙이 있었다. 과제1_2

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을 클래스의 인스턴스 생성 시에 자동으로 할 수 있다.
  • 이 코드의 동작을 구체적으로 살펴보면
    1. 데코레이터 정의에 따라 위 코드는 validate_username = field_validator(“username”)(validate_username) 과 동일함을 알 수 있다. field_validator(“username”)은 Pydantic에서 제공하는 함수로, 특정 필드에 검증 로직을 연결하기 위한 데코레이터를 생성. 여기서 “username”은 validator가 적용될 Pydantic 모델의 필드를 지정함. field_validator는 데코레이터 객체(데코레이터 게시물에서 wrapper function + 알파의 개념)를 반환함. 이 데코레이터는 특정 함수(validator)를 감싸고, 해당 함수와 필드의 연결을 관리하는 역할을 함.
    2. 위에서 생성된 데코레이터에 따라 코드는 validate_username = decorator(validate_username) 의 형태가 되고 Pydantic이 validate_username 함수를 “username” 필드의 검증 로직으로 등록한다.
    3. 전달된 validate_username 함수가 “username” 필드와 연결되면, Pydantic 모델은 이 연결 정보를 젖아하여, 나중에 “username” 필드 값이 설정되거나 초기화 될 때 이 검증 함수가 호출되도록 만든다.
  • 더 큰 흐름에서 이 코드는
    1. FastAPI에서 POST 요청으로 데이터를 받고, 이를 Pydantic 모델로 처리. 클라이언트가 /signup 엔드포인트로 JSON 데이터를 보내면 FastAPI는 요청 데이터를 읽고, 이를 Pydantic 모델인 UserSignupRequest로 변환하려 시도함. 요청 데이터를 UserSignupRequest의 필드 username, email, password와 매핑함.
    2. 매핑이 완료되면 필드별로 정의된 @field_validator가 실행됨. username을 예시로 들면,
      • validate_username(cls, username) 메서드가 호출됨.
      • username 값 “hoseok”이 밸리데이터 로직에 전달됨.
      • 반환된 값이 username 필드에 다시 저장됨.
    3. 만약 밸리데이터가 예외를 발생시키면 요청은 즉시 중단되고 FastAPI가 에러 응답을 반환함. 모든 밸리데이터가 통과되면, UserSignupRequest 인스턴스가 생성됨.
    4. 유효성 검사를 통과한 데이터를 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

layeredArchitecture

  • 코드가 복잡해짐에 따라, 관심사의 분리가 필요해짐. 코드를 수정해야 할 때 어디부터 건드려야 할지 난감하거나, 특정 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에서는 명확히 두 레이어가 구분됨.

layeredArchitecture2

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

layeredArchitecture3

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

Domain-Driven Design

DomainDrivenDesign

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

Leave a Comment