slowlp
← 블로그
방법 2026.06.22 · 5분 읽기

경계마다 검증한다: 스키마(DTO)와 다층 방어

스키마(DTO)로 모델을 그대로 내보내지 않고 계층마다 다른 책임으로 검증하기

방법

모델을 그대로 내보내지 않고 계층마다 다른 책임으로 검증한다

테이블 모델을 그대로 API 응답으로 내보내면 안 된다. 이걸 처음 들었을 때는 좀 과한 규칙 같았다. 어차피 같은 데이터인데 왜 따로 객체를 만들어? 그런데 한 번 사고를 쳐 보면 안다. 모델에는 secret_name 같은, 밖에 절대 나가면 안 되는 필드가 섞여 있다. 모델을 그대로 반환한다는 건 그 필드까지 같이 직렬화돼서 클라이언트로 흘러나간다는 뜻이다. DTO는 그 사고를 구조적으로 막아주는 장치다.

DTO는 경계에 세우는 별도의 객체

DTO(Data Transfer Object), 이 가이드에서는 스키마라고 부른다. API가 외부와 데이터를 주고받는 그 경계에서만 쓰는 객체다. 안쪽의 테이블 모델과는 의도적으로 분리해 둔다.

용도별로 나뉜다. 받을 때 쓰는 요청 DTO(Create, Update)와 내보낼 때 쓰는 응답 DTO(Public)다. 공통 필드는 {feature}Base에 몰아두고 나머지가 상속해서 중복을 없앤다.

class PortfolioStockBase(BaseSchema):
    target_weight: Decimal | None = Field(default=Decimal("0.00"))
    rebalance_yn: str = Field(default="Y")
    memo: str | None = Field(default=None)

# 받을 때: 필요한 모든 필드
class PortfolioStockCreate(PortfolioStockBase, BaseCreateSchema[PortfolioStocks]):
    portfolio_id: int
    stock_id: int

# 내보낼 때: 공개해도 되는 것만
class PortfolioStockPublic(PortfolioStockBase):
    id: int
    portfolio_id: int
    stock_id: int

응답 DTO가 핵심이다. Public은 내보내도 괜찮은 필드만 골라 담는다. 모델에 뭐가 더 붙든 응답에는 여기 적힌 것만 나간다. 빼먹어서 새는 게 아니라, 적은 것만 통과시키는 화이트리스트 방식이다.

한 군데서만 검증하면 왜 안 되나

검증 얘기로 넘어가면 보통 이런 생각을 한다. 입구에서 한 번 빡세게 거르면 되는 거 아냐? 안 된다. 계층마다 확인할 수 있는 게 다르기 때문이다. 그래서 이 가이드는 다층 방어(Defense in Depth)를 쓴다. 요청이 들어와서 DB에 닿기까지 네 번 검문을 받는다.

DTO 계층 — 첫 방어선. 들어온 데이터 자체가 말이 되는지 본다. 가격이 0보다 큰가, 이메일 형식인가, 비밀번호가 8자 이상인가. Pydantic의 Field@model_validator로 처리한다.

class UserCreate(SQLModel):
    email: EmailStr
    password: str = Field(min_length=8)
    age: int | None = Field(default=None, gt=0, le=150)

여기서 막을 수 있는 건 딱 형식과 값의 범위까지다. DTO는 DB를 모른다. 그래서 다음 검문이 필요하다.

라우터/서비스 계층 — 이 데이터가 현실과 맞는지 본다. 요청에 들어온 그 ID가 DB에 실제로 있는가? 이 사람이 그걸 건드릴 권한이 있는가? 형식만 맞고 존재하지 않는 ID는 DTO가 절대 못 잡는다. 조회를 해 봐야 알 수 있고, 조회는 서비스의 몫이다.

post = await post_repository.get(post_id, session)
if not post:
    raise NotFoundException("게시물을 찾을 수 없습니다.")
if post.owner_id != user_id:
    raise ForbiddenException("이 게시물을 수정할 권한이 없습니다.")

Model 계층 — 객체가 지금 이 동작을 해도 되는 상태인지 스스로 확인한다. 결제 안 된 주문은 배송을 못 보낸다, 포인트가 모자라면 못 산다. 이건 입력값 문제가 아니라 객체의 상태 문제라서, 객체 본인이 가장 잘 안다.

def ship_order(self):
    if self.status != "paid":
        raise ConflictException(f"결제되지 않은 주문(상태: {self.status})은 배송할 수 없습니다.")
    self.status = "shipped"

여기서 예외 선택에도 결이 있다. 상태 충돌은 ConflictException(409), 단순 입력값 위반은 BadRequestException(400)이다. “지금 서버 상태랑 안 맞는다”와 “네가 보낸 값이 틀렸다”는 다른 얘기니까.

그리고 마지막이 DB다. NOT NULL, UNIQUE 같은 제약으로 최후의 선을 지킨다.

같은 책임을 두 번 지지 않는다

각 계층이 자기가 볼 수 있는 것만 본다는 게 포인트다. DTO는 형식, 서비스는 존재와 권한, 모델은 상태, DB는 무결성. 위 계층이 막아준다고 아래에서 안 막는 게 아니라, 아래는 위가 원천적으로 알 수 없는 걸 막는다. 검증이 중복되는 게 아니라 책임이 다른 거다.

처음엔 네 군데서 검사하는 게 비효율로 보였는데, 각 계층이 자기 자리에서만 알 수 있는 정보를 책임진다고 생각하니 정리가 됐다. 입구에서 다 막으려 들면 입구가 DB를 알아야 하고, 결국 계층이 무너진다.

관련 프로젝트
주식앱 (마이폴리오) LAB
개별 종목 분석 없이 포트폴리오 단위로 투자하는 앱 — KIS 계좌 연동 실주문까지, 본인 계좌 전용으로 운영 중.
보기 →
댓글