자산 앱 프로젝트의 모델링(ERD) 구조

소개글에서 밝힌 바와 같이 저는 자산을 관리하는 앱을 만들고 있습니다. 이번 글에서는 프로젝트에서 사용된 데이터 모델링(ERD) 구조를 바탕으로, 각 테이블의 구조, 필드 설명, 그리고 FastAPI ORM(SQLModel)이 실제 DB 테이블을 자동으로 생성하는 원리까지 자세히 다룹니다.

목차

  1. ERD 설계 개념
  2. 모델과 물리 테이블의 관계
  3. 주요 테이블 구조
  4. 테이블 간 관계(ERD 구조 설명)
  5. 모델 설계 시 고려사항
  6. 마무리 및 확장 방향
  7. 참고

ERD 설계 개념

본 프로젝트에서는 Python의 SQLModel을 활용하여 데이터 모델링을 구현했습니다.
SQLModel은 SQLAlchemyPydantic을 결합한 ORM(Object Relational Mapping) 프레임워크로,
데이터 검증과 DB 매핑을 동시에 처리할 수 있습니다.

즉, Python 코드 내에서 모델 클래스를 정의하면,
그 구조가 곧 **데이터베이스 테이블 스키마(DDL)**로 변환됩니다.

이 덕분에 개발자는 DB에 직접 SQL을 작성하지 않아도,
모델 정의만으로 물리 테이블을 자동 생성할 수 있습니다.


모델과 물리 테이블의 관계

FastAPI + SQLModel 기반 프로젝트에서는 models.py에 정의한 클래스가 곧 테이블의 설계도가 됩니다.

예를 들어 다음 코드처럼 User 모델을 정의하면:

class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str
    nickname: str

이 코드를 기준으로 FastAPI가 애플리케이션 실행 시 다음 SQL을 자동으로 생성합니다:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR NOT NULL,
    nickname VARCHAR NOT NULL
);

이 과정은 SQLModel.metadata.create_all(engine) 구문을 통해 이루어집니다.
즉, 모델 클래스와 실제 DB의 물리 테이블은 1:1로 매핑됩니다.

✅ 자동 테이블 생성의 장점

  • SQL 작성 불필요: 개발자는 Python 코드로 모델만 정의하면 됨.
  • 스키마 일관성 보장: 모델과 DB 구조가 자동 동기화됨.
  • 마이그레이션 용이: Alembic과 함께 사용 시 버전 관리 가능.
  • 생성/수정 시각 필드 자동 관리: 모든 테이블에 created_at, updated_at을 두어 변경 추적 가능.

⚠️ 단, 이미 존재하는 테이블 구조는 자동 변경되지 않습니다.
모델만 수정했다고 해서 DB 구조가 자동으로 업데이트되지는 않습니다.
모델을 수정사항을 테이블에 반영하는 방법은 별도의 글로 다룰 예정입니다.


주요 테이블 구조

이제 실제 프로젝트의 주요 테이블 구조를 살펴보겠습니다.


1️⃣ User 테이블

User 테이블은 구글 인증을 통해 로그인하는 사용자의 정보를 저장합니다.
비밀번호 대신 이메일을 주요 식별자로 사용합니다.

필드명타입설명
idint (PK)사용자 고유 식별자
emailstr구글 로그인 이메일
nicknamestr닉네임
created_atdatetime생성 시각
updated_atdatetime수정 시각
class User(SQLModel, table=True):
    __tablename__ = "users"
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str
    nickname: str
    created_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    portfolios: List["Portfolio"] = Relationship(back_populates="user")

2️⃣ Portfolio 테이블

Portfolio는 사용자가 보유한 자산들의 묶음입니다.
예: “주식 투자 포트폴리오”, “가상자산”, “비상금 계좌”

필드명타입설명
idint (PK)포트폴리오 ID
namestr포트폴리오 이름
user_idint (FK)users.id 참조
created_atdatetime생성 시각
updated_atdatetime수정 시각
class Portfolio(SQLModel, table=True):
    __tablename__ = "portfolio"
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    user_id: Optional[int] = Field(default=None, foreign_key="users.id")
    created_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    user: Optional[User] = Relationship(back_populates="portfolios")
    assets: List["Asset"] = Relationship(back_populates="portfolio")

3️⃣ Asset 테이블

Asset은 실제 개별 자산 정보를 저장합니다.
예: “삼성전자 주식”, “현금 보유”, “국민은행 예금”

필드명타입설명
idint (PK)자산 고유 ID
namestr자산 이름
categorystr자산 종류 (주식, 예금 등)
amountfloat자산 금액
average_pricefloat평균 매입가
descriptionstr설명 (선택)
portfolio_idint (FK)portfolio.id 참조
created_atdatetime생성 시각
updated_atdatetime수정 시각
class Asset(SQLModel, table=True):
    __tablename__ = "asset"
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    category: str
    amount: float
    average_price: float = Field(default=0.0)
    description: Optional[str] = None
    portfolio_id: Optional[int] = Field(default=None, foreign_key="portfolio.id")
    created_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    portfolio: Optional[Portfolio] = Relationship(back_populates="assets")

4️⃣ Deposit 테이블

Deposit은 사용자가 포트폴리오에 입금한 내역을 기록합니다.

필드명타입설명
idint (PK)입금 ID
deposit_datedatetime입금 일시
amountint입금 금액
portfolio_idint (FK)portfolio.id 참조
memostr메모
created_atdatetime생성 시각
updated_atdatetime수정 시각
class Deposit(SQLModel, table=True):
    __tablename__ = "deposit"
    id: Optional[int] = Field(default=None, primary_key=True)
    deposit_date: datetime = Field(sa_column=Column(DateTime))
    amount: int
    portfolio_id: Optional[int] = Field(default=None)
    memo: Optional[str] = None
    created_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(KST))

5️⃣ Trade 테이블

Trade는 자산별 거래(매수/매도) 이력을 관리합니다.

필드명타입설명
idint (PK)거래 ID
symbolstr종목 티커
typeenum매매유형 (1:매수, 2:매도)
trade_datedatetime거래일
priceint거래가
quantityfloat수량
memostr메모
asset_idint (FK)asset.id 참조
created_atdatetime생성 시각
updated_atdatetime수정 시각
class Trade(SQLModel, table=True):
    __tablename__ = "trade"
    id: Optional[int] = Field(default=None, primary_key=True)
    symbol: str
    type: TradeType
    trade_date: datetime = Field(sa_column=Column(DateTime, nullable=False))
    price: int
    quantity: float
    memo: Optional[str] = None
    asset_id: Optional[int] = Field(default=None)
    created_at: datetime = Field(default_factory=lambda: datetime.now(KST))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(KST))

테이블 간 관계(ERD 구조 설명)

전체 ERD 구조는 다음과 같습니다.

User (1) ───< Portfolio (N)
Portfolio (1) ───< Asset (N)
Portfolio (1) ───< Deposit (N)
Asset (1) ───< Trade (N)

즉,

  • User는 여러 Portfolio를 가질 수 있고,
  • Portfolio는 여러 AssetDeposit을 포함하며,
  • Asset은 여러 Trade를 보유합니다.

이러한 구조는 실제 금융 앱의 자산 흐름을 매우 직관적으로 반영합니다.


모델 설계 시 고려사항

  1. 자동 테이블 생성
    • SQLModel.metadata.create_all(engine) 호출 시 모델 정의를 기반으로 테이블 자동 생성.
  2. 시간대 관리
    • 모든 시간(created_at, updated_at)은 KST(한국 표준시) 기준으로 기록.
  3. 데이터 정합성
    • 모든 관계형 데이터는 명시적 ForeignKey로 연결하여 무결성 확보.
  4. Optional 안전성
    • Optional 필드를 활용해 Null 입력에도 안전하게 동작하도록 설계.
  5. 변경 추적
    • 모든 모델에 updated_at을 추가해 데이터 변경 시점을 기록.

마무리 및 확장 방향

이 ERD 설계는 자산 관리 서비스의 데이터 흐름을 명확히 구조화하여
개발 속도, 유지보수성, 확장성을 모두 확보한 형태입니다.

향후에는 다음 기능을 고려할 수 있습니다.

  • 거래 내역 자동 집계 테이블 추가
  • 배당금/이자 수익 관리
  • 알림(Notification) 시스템과 연계

참고

댓글 남기기