소개글에서 밝힌 바와 같이 저는 자산을 관리하는 앱을 만들고 있습니다. 이번 글에서는 프로젝트에서 사용된 데이터 모델링(ERD) 구조를 바탕으로, 각 테이블의 구조, 필드 설명, 그리고 FastAPI ORM(SQLModel)이 실제 DB 테이블을 자동으로 생성하는 원리까지 자세히 다룹니다.
목차
ERD 설계 개념
본 프로젝트에서는 Python의 SQLModel을 활용하여 데이터 모델링을 구현했습니다.
SQLModel은 SQLAlchemy와 Pydantic을 결합한 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 테이블은 구글 인증을 통해 로그인하는 사용자의 정보를 저장합니다.
비밀번호 대신 이메일을 주요 식별자로 사용합니다.
| 필드명 | 타입 | 설명 |
|---|---|---|
| id | int (PK) | 사용자 고유 식별자 |
| str | 구글 로그인 이메일 | |
| nickname | str | 닉네임 |
| created_at | datetime | 생성 시각 |
| updated_at | datetime | 수정 시각 |
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는 사용자가 보유한 자산들의 묶음입니다.
예: “주식 투자 포트폴리오”, “가상자산”, “비상금 계좌”
| 필드명 | 타입 | 설명 |
|---|---|---|
| id | int (PK) | 포트폴리오 ID |
| name | str | 포트폴리오 이름 |
| user_id | int (FK) | users.id 참조 |
| created_at | datetime | 생성 시각 |
| updated_at | datetime | 수정 시각 |
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은 실제 개별 자산 정보를 저장합니다.
예: “삼성전자 주식”, “현금 보유”, “국민은행 예금”
| 필드명 | 타입 | 설명 |
|---|---|---|
| id | int (PK) | 자산 고유 ID |
| name | str | 자산 이름 |
| category | str | 자산 종류 (주식, 예금 등) |
| amount | float | 자산 금액 |
| average_price | float | 평균 매입가 |
| description | str | 설명 (선택) |
| portfolio_id | int (FK) | portfolio.id 참조 |
| created_at | datetime | 생성 시각 |
| updated_at | datetime | 수정 시각 |
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은 사용자가 포트폴리오에 입금한 내역을 기록합니다.
| 필드명 | 타입 | 설명 |
|---|---|---|
| id | int (PK) | 입금 ID |
| deposit_date | datetime | 입금 일시 |
| amount | int | 입금 금액 |
| portfolio_id | int (FK) | portfolio.id 참조 |
| memo | str | 메모 |
| created_at | datetime | 생성 시각 |
| updated_at | datetime | 수정 시각 |
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는 자산별 거래(매수/매도) 이력을 관리합니다.
| 필드명 | 타입 | 설명 |
|---|---|---|
| id | int (PK) | 거래 ID |
| symbol | str | 종목 티커 |
| type | enum | 매매유형 (1:매수, 2:매도) |
| trade_date | datetime | 거래일 |
| price | int | 거래가 |
| quantity | float | 수량 |
| memo | str | 메모 |
| asset_id | int (FK) | asset.id 참조 |
| created_at | datetime | 생성 시각 |
| updated_at | datetime | 수정 시각 |
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는 여러 Asset과 Deposit을 포함하며,
- Asset은 여러 Trade를 보유합니다.
이러한 구조는 실제 금융 앱의 자산 흐름을 매우 직관적으로 반영합니다.
모델 설계 시 고려사항
- 자동 테이블 생성
SQLModel.metadata.create_all(engine)호출 시 모델 정의를 기반으로 테이블 자동 생성.
- 시간대 관리
- 모든 시간(
created_at,updated_at)은KST(한국 표준시) 기준으로 기록.
- 모든 시간(
- 데이터 정합성
- 모든 관계형 데이터는 명시적 ForeignKey로 연결하여 무결성 확보.
- Optional 안전성
Optional필드를 활용해 Null 입력에도 안전하게 동작하도록 설계.
- 변경 추적
- 모든 모델에
updated_at을 추가해 데이터 변경 시점을 기록.
- 모든 모델에
마무리 및 확장 방향
이 ERD 설계는 자산 관리 서비스의 데이터 흐름을 명확히 구조화하여
개발 속도, 유지보수성, 확장성을 모두 확보한 형태입니다.
향후에는 다음 기능을 고려할 수 있습니다.
- 거래 내역 자동 집계 테이블 추가
- 배당금/이자 수익 관리
- 알림(Notification) 시스템과 연계