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

모델에 로직을 넣으면 중복이 사라진다

ORM 도메인 모델로 같은 수정 규칙을 모델 메서드 한 곳에 모아 중복 없애기

방법

모델에 로직을 넣으면 중복이 사라진다: ORM 도메인 모델

같은 수정 규칙을 세 군데에 복붙하지 않으려면 모델 메서드로 모은다

1편에서 회사 코드가 죄다 복붙이라고 했다. 그 중복이 제일 굵게 쌓이는 자리가 여기, 로직을 어디에 두느냐다.

모델을 그냥 표로 보면 로직이 흩어진다

ORM 모델을 처음 배울 때 나는 그걸 그냥 “테이블을 파이썬 클래스로 적어둔 것”으로 봤다. 컬럼 나열하고, 관계 걸고, 끝. 데이터의 모양만 거기 있고, 그 데이터로 뭘 하는지는 전부 서비스에 적었다.

그러다 보면 같은 규칙이 여러 군데서 반복된다. 예를 들어 어떤 항목을 수정할 때 허용된 필드만 바꾸고 “누가 언제 바꿨는지”를 기록해야 한다고 치자. 이걸 수정 API에서 한 번 쓰고, 외부 데이터를 가져와 갱신하는 작업에서 또 쓰고, 정합성을 맞추는 배치에서 또 쓴다. 셋 다 같은 일을 하는데 코드는 세 군데에 복붙돼 있다. 한 곳에서 규칙이 바뀌면 나머지 둘을 손으로 찾아 고쳐야 하고, 빠뜨리면 그게 버그가 된다.

이 스터디 가이드를 정리하면서 잡은 원칙은 이거다. 데이터의 구조뿐 아니라 그 데이터에 관련된 행위(비즈니스 로직)까지 모델 클래스에 넣는다. 객체지향에서 말하는 그 모델링이다. 모델이 “표”가 아니라 “행위를 가진 객체”가 되면, 흩어지던 규칙이 한 곳으로 모인다.

BaseSqlModel: 공통을 한 번만 적는다

모든 테이블 모델이 같은 기반 클래스를 상속받게 했다. 이름은 BaseSqlModel.

class BaseSqlModel(SQLModel):
    id: int | None = Field(default=None, primary_key=True)
    inserted_by: str
    inserted_at: datetime
    updated_by: str
    updated_at: datetime

    def insert(self, session_id: str, now: datetime | None = None) -> None:
        ...

    def update(self, session_id: str, now: datetime | None = None, **kwargs) -> None:
        ...

여기 두 가지가 들어있다. 하나는 모든 테이블이 공통으로 갖는 필드 — id, 그리고 “언제 누가 만들고 고쳤는지”를 남기는 감사 필드(inserted_by/inserted_at/updated_by/updated_at). 다른 하나는 그 감사 필드를 채워주는 헬퍼 insert·update다. insert를 부르면 만든 주체와 시각이, update를 부르면 고친 주체와 시각이 자동으로 들어간다. 채워지는 값은 사용자 ID가 아니라 세션 ID라는 점만 기억하면 된다(누가 했는지를 세션 단위로 기록).

이걸 베이스에 박아두면, 새 테이블을 만들 때마다 같은 다섯 필드를 다시 쓰거나 “고칠 때 updated_at 갱신하는 거 잊지 말기”를 매번 신경 쓸 일이 없다. 공통은 한 번만 적혀 있고, 상속만 하면 따라온다.

행위를 모델 메서드로

기반을 깔았으면, 이제 각 모델에 그 모델만의 행위를 메서드로 넣는다. 포트폴리오 모델로 보면 이렇다.

class Portfolios(BaseSqlModel, table=True):
    __tablename__ = "portfolio"
    account_id: int = Field(foreign_key="account.id")
    portfolio_name: str = Field(min_length=1, max_length=100, index=True)

    def update_portfolio(self, update_data: dict, session_id: str) -> None:
        for field, value in update_data.items():
            setattr(self, field, value)
        self.updated_by = session_id
        self.updated_at = datetime.now()

아까 “여러 군데서 복붙되던” 그 수정 규칙이 지금은 update_portfolio 하나에 들어있다. 허용 필드만 바꾸고 감사 필드를 채우는 일을 이 메서드가 캡슐화한다. 수정 API든, 외부 데이터 import든, 정합성 배치든, 전부 이 메서드를 호출하기만 하면 된다. 규칙이 바뀌면 여기 한 곳만 고친다. 복붙된 사본을 추적해 다 고칠 필요가 없다.

가이드에서는 이런 모델 메서드를 역할에 따라 세 종류로 나눴다. 상태나 영속 필드를 바꾸는 write-path(이건 변경이 저장되니 self.update(...)로 감사 필드를 갱신한다), 생성 규칙을 캡슐화하는 팩토리(@classmethod로 일관된 인스턴스를 만들어 반환), 그리고 조회 시점에 파생되는 값을 계산하는 읽기 경로(현재가 평가나 원가 산정 같은 것. 저장되는 변경이 아니라서 감사 필드를 건드리지 않는다). 구분의 핵심은 “감사 필드를 채우느냐”다. 저장될 변경이면 채우고, 단순 계산이면 안 채운다.

한 가지 규칙도 같이 잡았다. 이 모델 메서드는 순수 도메인으로 유지한다. session이나 redis, 리포지토리, 외부 API의 응답 객체 같은 걸 인자로 받지 않는다. 외부 값은 숫자나 다른 모델 인스턴스 같은 형태로만 받는다. 이렇게 두면 모델만 떼어내 단위 테스트할 수 있다. DB를 띄우지 않고 객체 하나만 만들어 메서드를 호출해 검증하면 된다.

서비스는 모델을 부른다, 베끼지 않는다

그러면 서비스는 뭘 하나. 서비스는 오케스트레이션을 한다. 리포지토리로 데이터를 읽어오고, 트랜잭션 경계를 잡고, 외부 API를 호출하고, 여러 모델을 합산하는 집계를 하고, 정합성을 검증한다. 그리고 단일 모델에 대한 계산·상태 전이·생성은 직접 적지 않고 모델 메서드를 부른다.

라우터와 비동기 작업(워커)이 같은 일을 해야 할 때 이 구조가 특히 산다. 가이드의 예시는 계좌 잔고 갱신이다. 잔고 동기화 로직은 서비스 함수 하나에 있고, 잔고 갱신 API와 매매 워커가 둘 다 그 같은 서비스를 호출한다.

# 라우터
new_balance = await refresh_account_balance(
    session=session, redis=redis, account=account,
    encrypted_payload=body.encrypted_payload,
    session_id=session_user.session_id,
)
# 워커(태스크)도 같은 서비스를 호출
await sync_account_balance(
    session=self.session, redis=kis_token_redis, account=account,
    encrypted_payload=envelope, session_id="celery-worker", ...
)

라우터는 응답을 만들고, 워커는 백그라운드에서 돌고, 하는 일은 다르지만 비즈니스 규칙은 한 곳에 있다. 라우터 안에 로직을 적고 워커에도 같은 걸 적었다면, 둘이 미묘하게 달라지는 순간 버그가 생긴다.

정리하면 책임이 두 층으로 나뉜다. 모델은 “자기 자신에 대한 규칙”을 갖고, 서비스는 “여러 모델을 엮고 바깥과 연결하는 흐름”을 갖는다. 같은 규칙이 두 번 적히지 않게 되는 건 이 분담의 결과다. 모델로 끌어올릴 수 있는 건 모델로 올리고, 서비스는 그걸 호출만 한다.

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