o2o-castad-backend/docs/reference/sqlalchemy_relationship_gui...

1441 lines
45 KiB
Markdown

# SQLAlchemy Relationship 완벽 가이드
> SQLAlchemy 2.0+ / Python 3.10+ 기준
---
## 목차
1. [개요](#1-개요)
2. [기본 개념](#2-기본-개념)
3. [relationship 정의 문법](#3-relationship-정의-문법)
4. [부모와 자식에서의 정의 차이](#4-부모와-자식에서의-정의-차이)
5. [FK 필드 vs back_populates](#5-fk-필드-vs-back_populates)
6. [relationship 옵션 상세](#6-relationship-옵션-상세)
7. [관계별 정의 방법 (1:1, 1:N, N:M)](#7-관계별-정의-방법-11-1n-nm)
8. [ORM 사용법](#8-orm-사용법)
9. [실무 패턴](#9-실무-패턴)
10. [Quick Reference](#10-quick-reference)
---
## 1. 개요
### 1.1 relationship이란?
`relationship()`은 SQLAlchemy ORM에서 **테이블 간의 관계를 Python 객체로 매핑**하는 기능입니다.
```
┌─────────────────────────────────────────────────────────┐
│ Database Level ORM Level │
│ ────────────── ───────── │
│ FOREIGN KEY → relationship() │
│ JOIN 쿼리 → object.related_objects │
└─────────────────────────────────────────────────────────┘
```
### 1.2 ForeignKey vs relationship
| 구분 | ForeignKey | relationship |
|------|------------|--------------|
| **역할** | DB 레벨 제약조건 | ORM 레벨 객체 연결 |
| **위치** | 자식 테이블에만 | 양쪽 모두 가능 |
| **필수 여부** | FK 관계에 필수 | 선택사항 (편의 기능) |
| **결과** | 컬럼 생성 | Python 속성 생성 |
```python
# ForeignKey: DB에 실제 컬럼 생성
project_id: Mapped[int] = mapped_column(ForeignKey("project.id"))
# relationship: Python 객체 접근 경로 생성
project: Mapped["Project"] = relationship("Project")
```
---
## 2. 기본 개념
### 2.1 용어 정의
```
┌─────────────────────────────────────────────────────────┐
│ 부모 (Parent) 자식 (Child) │
│ ───────────── ───────────── │
│ • "One" 쪽 • "Many" 쪽 │
│ • FK를 참조받음 • FK를 정의함 │
│ • 예: Project • 예: Image │
└─────────────────────────────────────────────────────────┘
```
### 2.2 관계 방향
```python
# 단방향 (Unidirectional)
# - 한쪽에서만 relationship 정의
# - 반대쪽 접근 불가
# 양방향 (Bidirectional)
# - 양쪽 모두 relationship 정의
# - back_populates로 연결
# - 실무 권장 방식
```
### 2.3 기본 예제 (가장 간단한 형태)
```python
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
# 부모 테이블
class Project(Base):
__tablename__ = "project"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
# 자식들에 대한 접근 경로
images: Mapped[list["Image"]] = relationship("Image", back_populates="project")
# 자식 테이블
class Image(Base):
__tablename__ = "image"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) # FK
filename: Mapped[str] = mapped_column(String(255))
# 부모에 대한 접근 경로
project: Mapped["Project"] = relationship("Project", back_populates="images")
```
---
## 3. relationship 정의 문법
### 3.1 기본 문법
```python
from sqlalchemy.orm import relationship, Mapped
from typing import List
# 기본 형태
속성명: Mapped[타입] = relationship("대상클래스", 옵션들...)
# 부모 측 (1:N에서 "1")
children: Mapped[List["Child"]] = relationship("Child", back_populates="parent")
# 자식 측 (1:N에서 "N")
parent: Mapped["Parent"] = relationship("Parent", back_populates="children")
```
### 3.2 필수 항목
| 항목 | 설명 | 예시 |
|------|------|------|
| **첫 번째 인자** | 대상 모델 클래스명 (문자열) | `"Project"` |
```python
# 최소한의 정의 (단방향)
project: Mapped["Project"] = relationship("Project")
```
### 3.3 권장 항목
| 항목 | 설명 | 예시 |
|------|------|------|
| `back_populates` | 반대편 relationship 속성명 | `back_populates="images"` |
```python
# 권장하는 정의 (양방향)
project: Mapped["Project"] = relationship("Project", back_populates="images")
```
---
## 4. 부모와 자식에서의 정의 차이
### 4.1 구조 비교
```
┌─────────────────────────────────────────────────────────────────┐
│ 부모 (Parent) │
├─────────────────────────────────────────────────────────────────┤
│ • ForeignKey: 없음 │
│ • relationship 타입: List["Child"] 또는 list["Child"] │
│ • cascade 옵션: 여기에 정의 (삭제 정책) │
│ • 역할: 자식 컬렉션에 대한 접근 제공 │
└─────────────────────────────────────────────────────────────────┘
│ 참조
┌─────────────────────────────────────────────────────────────────┐
│ 자식 (Child) │
├─────────────────────────────────────────────────────────────────┤
│ • ForeignKey: 있음 (필수) │
│ • relationship 타입: "Parent" (단수) │
│ • cascade 옵션: 일반적으로 정의 안 함 │
│ • 역할: 부모 객체에 대한 접근 제공 │
└─────────────────────────────────────────────────────────────────┘
```
### 4.2 부모 클래스 정의
```python
class Project(Base):
"""부모 클래스 - FK 없음, 자식 컬렉션 관리"""
__tablename__ = "project"
id: Mapped[int] = mapped_column(primary_key=True)
store_name: Mapped[str] = mapped_column(String(255))
# ─────────────────────────────────────────────────────
# relationship 정의 (부모 측)
# ─────────────────────────────────────────────────────
images: Mapped[list["Image"]] = relationship(
"Image", # 대상 클래스
back_populates="project", # 자식의 relationship 속성명
cascade="all, delete-orphan", # 삭제 정책 (부모에서 정의)
lazy="selectin", # 로딩 전략
order_by="Image.created_at", # 정렬 (선택)
)
```
**부모 측 특징:**
- `Mapped[list["Child"]]` - 복수형 리스트 타입
- `cascade` 옵션을 여기서 정의
- FK 컬럼 없음
### 4.3 자식 클래스 정의
```python
class Image(Base):
"""자식 클래스 - FK 있음, 부모 참조"""
__tablename__ = "image"
id: Mapped[int] = mapped_column(primary_key=True)
original_filename: Mapped[str] = mapped_column(String(255))
# ─────────────────────────────────────────────────────
# ForeignKey 정의 (자식 측에서만)
# ─────────────────────────────────────────────────────
project_id: Mapped[int] = mapped_column(
ForeignKey("project.id", ondelete="CASCADE")
)
# ─────────────────────────────────────────────────────
# relationship 정의 (자식 측)
# ─────────────────────────────────────────────────────
project: Mapped["Project"] = relationship(
"Project", # 대상 클래스
back_populates="images", # 부모의 relationship 속성명
)
```
**자식 측 특징:**
- `Mapped["Parent"]` - 단수형 타입
- FK 컬럼 정의 필수
- cascade는 보통 정의하지 않음
### 4.4 비교 표
| 항목 | 부모 (One) | 자식 (Many) |
|------|-----------|-------------|
| **ForeignKey** | ❌ 없음 | ✅ 필수 |
| **Mapped 타입** | `list["Child"]` | `"Parent"` |
| **cascade** | ✅ 여기서 정의 | ❌ 보통 안 함 |
| **back_populates** | 자식의 속성명 | 부모의 속성명 |
| **접근 결과** | 리스트 | 단일 객체 |
---
## 5. FK 필드 vs back_populates
### 5.1 두 가지 접근 방식
```python
class Image(Base):
# 방식 1: FK 필드 직접 사용
project_id: Mapped[int] = mapped_column(ForeignKey("project.id"))
# 방식 2: relationship 사용
project: Mapped["Project"] = relationship("Project", back_populates="images")
```
### 5.2 FK 필드 직접 사용
```python
# ─────────────────────────────────────────────────────
# FK 필드로 직접 조작
# ─────────────────────────────────────────────────────
# 생성
image = Image(
project_id=1, # FK 값 직접 지정
original_filename="photo.jpg"
)
session.add(image)
session.commit()
# 수정
image = session.get(Image, 1)
image.project_id = 2 # FK 값 직접 변경
session.commit()
# 조회
image = session.get(Image, 1)
print(image.project_id) # 정수 값: 1
# 부모 객체 접근하려면 추가 쿼리 필요
project = session.get(Project, image.project_id)
```
**특징:**
- 단순 정수 값 조작
- 객체 관계를 신경 쓰지 않음
- 추가 쿼리 없이 FK 값만 필요할 때 유용
### 5.3 relationship (back_populates) 사용
```python
# ─────────────────────────────────────────────────────
# relationship으로 객체 조작
# ─────────────────────────────────────────────────────
# 생성 - 객체로 연결
project = session.get(Project, 1)
image = Image(
project=project, # 객체로 연결 (FK 자동 설정)
original_filename="photo.jpg"
)
session.add(image)
session.commit()
print(image.project_id) # 자동으로 1 설정됨!
# 수정 - 객체로 변경
image = session.get(Image, 1)
new_project = session.get(Project, 2)
image.project = new_project # 객체로 변경
session.commit()
print(image.project_id) # 자동으로 2로 변경됨!
# 조회 - 객체 직접 접근
image = session.get(Image, 1)
print(image.project.store_name) # 바로 객체 속성 접근
```
**특징:**
- 객체 지향적 접근
- FK 값 자동 동기화
- 양방향 자동 업데이트
### 5.4 양방향 동기화 동작
```python
# back_populates의 핵심: 양쪽 자동 동기화
project = Project(store_name="카페")
image = Image(original_filename="photo.jpg")
# 방법 1: 부모에 자식 추가
project.images.append(image)
print(image.project) # <Project ...> - 자동 설정!
print(image.project_id) # None (아직 flush 전)
session.add(project)
session.flush()
print(image.project_id) # 1 - flush 후 FK 설정됨
# 방법 2: 자식에 부모 지정
image2 = Image(original_filename="logo.png")
image2.project = project
print(image2 in project.images) # True - 자동 추가!
```
### 5.5 비교 표
| 상황 | FK 필드 사용 | relationship 사용 |
|------|-------------|------------------|
| **부모 ID만 필요** | ✅ `image.project_id` | ⚠️ 불필요한 객체 로딩 가능 |
| **부모 객체 접근** | ❌ 추가 쿼리 필요 | ✅ `image.project.name` |
| **자식 추가** | `image.project_id = 1` | `project.images.append(image)` |
| **일괄 생성** | FK 값 직접 지정 | 객체 연결로 자동 설정 |
| **양방향 동기화** | ❌ 수동 관리 | ✅ 자동 |
### 5.6 공식 권장 사용 방식
```python
class Image(Base):
__tablename__ = "image"
id: Mapped[int] = mapped_column(primary_key=True)
# 1. FK 필드 - DB 레벨 관계 (필수)
project_id: Mapped[int] = mapped_column(ForeignKey("project.id"))
# 2. relationship - ORM 레벨 편의 기능 (권장)
project: Mapped["Project"] = relationship("Project", back_populates="images")
# 사용 시:
# - 단순 FK 값 조회/설정: project_id 사용
# - 객체 조작/탐색: project (relationship) 사용
image = session.get(Image, 1)
print(image.project_id) # 빠름 (추가 쿼리 없음)
print(image.project.store_name) # 객체 접근 (필요시 쿼리)
```
---
## 6. relationship 옵션 상세
### 6.1 필수/권장 옵션
#### 첫 번째 인자: 대상 클래스
```python
# 문자열 (Forward Reference) - 권장
relationship("Project")
# 클래스 직접 참조 (순환 import 주의)
relationship(Project)
```
#### back_populates (양방향 연결)
```python
# 부모 측
images: Mapped[list["Image"]] = relationship(
"Image",
back_populates="project" # Image.project와 연결
)
# 자식 측
project: Mapped["Project"] = relationship(
"Project",
back_populates="images" # Project.images와 연결
)
```
### 6.2 cascade 옵션
자식 객체의 생명주기 관리. **부모 측에서 정의**합니다.
```python
images: Mapped[list["Image"]] = relationship(
"Image",
cascade="all, delete-orphan", # 가장 일반적
)
```
#### cascade 값 종류
| 값 | 설명 |
|----|------|
| `save-update` | 부모 저장 시 자식도 저장 (기본값에 포함) |
| `merge` | 부모 merge 시 자식도 merge |
| `expunge` | 부모 expunge 시 자식도 expunge |
| `delete` | 부모 삭제 시 자식도 삭제 |
| `delete-orphan` | 부모에서 분리된 자식 삭제 |
| `refresh-expire` | 부모 refresh 시 자식도 refresh |
| `all` | 위 모든 것 (delete-orphan 제외) |
#### 일반적인 조합
```python
# 1. 기본값 (자동 적용)
cascade="save-update, merge"
# 2. 부모 삭제 시 자식도 삭제
cascade="all, delete"
# 3. 부모에서 분리 시에도 삭제 (가장 엄격)
cascade="all, delete-orphan"
# 4. 삭제 방지 (자식은 독립적)
cascade="save-update, merge" # delete 없음
```
#### cascade 동작 예시
```python
class Project(Base):
images: Mapped[list["Image"]] = relationship(
"Image",
cascade="all, delete-orphan",
)
# delete-orphan 동작
project = session.get(Project, 1)
image = project.images[0]
project.images.remove(image) # 부모에서 분리
session.commit()
# → image가 DB에서도 삭제됨 (orphan이 됨)
# delete 동작
project = session.get(Project, 1)
session.delete(project)
session.commit()
# → 모든 project.images도 삭제됨
```
### 6.3 lazy 옵션 (로딩 전략)
관계 데이터를 언제/어떻게 로딩할지 결정합니다.
```python
images: Mapped[list["Image"]] = relationship(
"Image",
lazy="selectin", # 로딩 전략
)
```
#### lazy 값 종류
| 값 | 로딩 시점 | 쿼리 방식 | 사용 상황 |
|----|----------|----------|----------|
| `select` | 접근 시 (기본값) | 개별 SELECT | 거의 사용 안 함 |
| `selectin` | 부모 로딩 후 | SELECT ... IN | **1:N 권장** |
| `joined` | 부모와 함께 | JOIN | 1:1, N:1 권장 |
| `subquery` | 부모 로딩 후 | 서브쿼리 | 복잡한 경우 |
| `raise` | 접근 시 에러 | - | 명시적 로딩 강제 |
| `noload` | 로딩 안 함 | - | 특수 상황 |
| `dynamic` | Query 객체 반환 | - | 대량 데이터 |
| `write_only` | 쓰기 전용 | - | 대량 데이터 (2.0) |
#### 로딩 전략 예시
```python
# 1. Lazy Loading (기본값) - N+1 문제 발생!
class Project(Base):
images: Mapped[list["Image"]] = relationship("Image", lazy="select")
projects = session.scalars(select(Project)).all()
for project in projects:
print(project.images) # 매번 쿼리 발생! (N+1)
# 2. selectin - 권장 (1:N)
class Project(Base):
images: Mapped[list["Image"]] = relationship("Image", lazy="selectin")
projects = session.scalars(select(Project)).all()
# 쿼리 1: SELECT * FROM project
# 쿼리 2: SELECT * FROM image WHERE project_id IN (1, 2, 3, ...)
for project in projects:
print(project.images) # 추가 쿼리 없음
# 3. joined - 권장 (1:1, N:1)
class Image(Base):
project: Mapped["Project"] = relationship("Project", lazy="joined")
images = session.scalars(select(Image)).all()
# 쿼리: SELECT image.*, project.* FROM image JOIN project ...
for image in images:
print(image.project.name) # 추가 쿼리 없음
# 4. raise - 명시적 로딩 강제
class Project(Base):
images: Mapped[list["Image"]] = relationship("Image", lazy="raise")
project = session.get(Project, 1)
print(project.images) # 에러! 명시적 로딩 필요
```
### 6.4 uselist 옵션
반환 타입을 리스트/단일 객체로 지정합니다.
```python
# 기본값: True (리스트)
images: Mapped[list["Image"]] = relationship("Image", uselist=True)
# 1:1 관계에서 단일 객체
profile: Mapped["Profile"] = relationship("Profile", uselist=False)
```
### 6.5 foreign_keys 옵션
여러 FK가 있을 때 명시적 지정이 필요합니다.
```python
class Message(Base):
sender_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
receiver_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
# 어떤 FK를 사용할지 명시
sender: Mapped["User"] = relationship(
"User",
foreign_keys=[sender_id],
)
receiver: Mapped["User"] = relationship(
"User",
foreign_keys=[receiver_id],
)
```
### 6.6 primaryjoin 옵션
복잡한 조인 조건을 직접 정의합니다.
```python
from sqlalchemy import and_
class Project(Base):
# 활성 이미지만 조회
active_images: Mapped[list["Image"]] = relationship(
"Image",
primaryjoin=lambda: and_(
Project.id == Image.project_id,
Image.is_active == True
),
viewonly=True,
)
```
### 6.7 order_by 옵션
자식 컬렉션의 기본 정렬 순서를 지정합니다.
```python
images: Mapped[list["Image"]] = relationship(
"Image",
order_by="Image.created_at.desc()", # 최신순
)
# 또는 명시적으로
from sqlalchemy import desc
images: Mapped[list["Image"]] = relationship(
"Image",
order_by=desc(Image.created_at),
)
```
### 6.8 viewonly 옵션
읽기 전용 관계로 지정합니다 (쓰기 비활성화).
```python
# 통계/조회용 관계
recent_images: Mapped[list["Image"]] = relationship(
"Image",
primaryjoin="and_(Project.id == Image.project_id, Image.created_at > func.now() - interval '7 days')",
viewonly=True, # 이 관계로는 추가/삭제 불가
)
```
### 6.9 옵션 종합 표
| 옵션 | 기본값 | 설명 | 주로 사용 위치 |
|------|--------|------|---------------|
| `back_populates` | None | 양방향 연결 | 양쪽 |
| `cascade` | `save-update, merge` | 삭제 정책 | 부모 |
| `lazy` | `select` | 로딩 전략 | 양쪽 |
| `uselist` | True | 리스트/단일 | 1:1에서 False |
| `foreign_keys` | 자동 감지 | FK 명시 | 복수 FK 시 |
| `primaryjoin` | 자동 생성 | 조인 조건 | 복잡한 조건 |
| `order_by` | None | 정렬 순서 | 부모 |
| `viewonly` | False | 읽기 전용 | 특수 관계 |
---
## 7. 관계별 정의 방법 (1:1, 1:N, N:M)
### 7.1 1:1 (One-to-One)
한 레코드가 다른 테이블의 한 레코드와만 연결됩니다.
```
┌─────────────┐ ┌─────────────┐
│ User │────────│ Profile │
│ (parent) │ 1:1 │ (child) │
└─────────────┘ └─────────────┘
```
#### 기본 예제
```python
class User(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
# 1:1 관계 - uselist=False
profile: Mapped["Profile"] = relationship(
"Profile",
back_populates="user",
uselist=False, # 핵심! 단일 객체 반환
cascade="all, delete-orphan",
)
class Profile(Base):
__tablename__ = "profile"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("user.id"),
unique=True # 1:1 보장
)
bio: Mapped[str | None] = mapped_column(Text)
user: Mapped["User"] = relationship(
"User",
back_populates="profile",
)
```
#### 1:1 핵심 포인트
```python
# 1. FK에 unique=True
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True)
# 2. 부모 relationship에 uselist=False
profile: Mapped["Profile"] = relationship("Profile", uselist=False)
```
#### 사용 예시
```python
# 생성
user = User(username="john")
user.profile = Profile(bio="Hello, I'm John") # 단일 객체 할당
session.add(user)
session.commit()
# 조회
user = session.get(User, 1)
print(user.profile.bio) # 직접 접근 (리스트 아님)
# 수정
user.profile.bio = "Updated bio"
session.commit()
# 교체
user.profile = Profile(bio="New profile") # 기존 profile은 orphan 삭제
session.commit()
```
### 7.2 1:N (One-to-Many)
한 레코드가 여러 레코드와 연결됩니다. **가장 일반적인 관계입니다.**
```
┌─────────────┐ ┌─────────────┐
│ Project │────────<│ Image │
│ (parent) │ 1:N │ (child) │
└─────────────┘ └─────────────┘
```
#### 기본 예제
```python
class Project(Base):
__tablename__ = "project"
id: Mapped[int] = mapped_column(primary_key=True)
store_name: Mapped[str] = mapped_column(String(255))
# 1:N 관계 - 리스트 타입
images: Mapped[list["Image"]] = relationship(
"Image",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
class Image(Base):
__tablename__ = "image"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(ForeignKey("project.id"))
filename: Mapped[str] = mapped_column(String(255))
# N:1 관계
project: Mapped["Project"] = relationship(
"Project",
back_populates="images",
)
```
#### 사용 예시
```python
# 생성 - 방법 1: 부모에 추가
project = Project(store_name="카페")
project.images.append(Image(filename="logo.png"))
project.images.append(Image(filename="photo.jpg"))
session.add(project)
session.commit()
# 생성 - 방법 2: 한번에 정의
project = Project(
store_name="카페",
images=[
Image(filename="logo.png"),
Image(filename="photo.jpg"),
]
)
session.add(project)
session.commit()
# 조회
project = session.get(Project, 1)
for image in project.images:
print(image.filename)
# 자식에서 부모 접근
image = session.get(Image, 1)
print(image.project.store_name)
# 자식 추가
project.images.append(Image(filename="new.png"))
session.commit()
# 자식 제거 (delete-orphan이면 DB에서도 삭제)
image_to_remove = project.images[0]
project.images.remove(image_to_remove)
session.commit()
```
### 7.3 N:M (Many-to-Many)
양쪽 모두 여러 레코드와 연결됩니다. **연결 테이블(Association Table)이 필요합니다.**
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Post │────────<│ post_tag │>────────│ Tag │
│ │ N:M │ (중간테이블) │ N:M │ │
└─────────────┘ └─────────────┘ └─────────────┘
```
#### 방법 1: Association Table (단순 연결)
```python
from sqlalchemy import Table, Column, Integer, ForeignKey
# 중간 테이블 정의 (모델 클래스 없이)
post_tag = Table(
"post_tag",
Base.metadata,
Column("post_id", Integer, ForeignKey("post.id"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tag.id"), primary_key=True),
)
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
# N:M 관계 - secondary로 중간 테이블 지정
tags: Mapped[list["Tag"]] = relationship(
"Tag",
secondary=post_tag, # 중간 테이블
back_populates="posts",
)
class Tag(Base):
__tablename__ = "tag"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True)
posts: Mapped[list["Post"]] = relationship(
"Post",
secondary=post_tag,
back_populates="tags",
)
```
#### N:M 사용 예시
```python
# 생성
post = Post(title="Python 팁")
tag1 = Tag(name="python")
tag2 = Tag(name="tutorial")
post.tags.append(tag1)
post.tags.append(tag2)
session.add(post)
session.commit()
# 또는 한번에
post = Post(
title="Python 팁",
tags=[Tag(name="python"), Tag(name="tutorial")]
)
# 조회
post = session.get(Post, 1)
for tag in post.tags:
print(tag.name)
tag = session.get(Tag, 1)
for post in tag.posts:
print(post.title)
# 태그 추가/제거
post.tags.append(existing_tag)
post.tags.remove(tag_to_remove) # 중간 테이블에서만 삭제
session.commit()
```
#### 방법 2: Association Object (추가 데이터 필요 시)
중간 테이블에 추가 컬럼이 필요한 경우 사용합니다.
```python
class PostTag(Base):
"""중간 테이블 - 추가 데이터 포함"""
__tablename__ = "post_tag"
post_id: Mapped[int] = mapped_column(ForeignKey("post.id"), primary_key=True)
tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True)
# 추가 데이터
created_at: Mapped[datetime] = mapped_column(default=func.now())
created_by: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
# 양쪽 관계
post: Mapped["Post"] = relationship("Post", back_populates="post_tags")
tag: Mapped["Tag"] = relationship("Tag", back_populates="post_tags")
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
post_tags: Mapped[list["PostTag"]] = relationship(
"PostTag",
back_populates="post",
cascade="all, delete-orphan",
)
# 편의를 위한 프로퍼티
@property
def tags(self) -> list["Tag"]:
return [pt.tag for pt in self.post_tags]
class Tag(Base):
__tablename__ = "tag"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
post_tags: Mapped[list["PostTag"]] = relationship(
"PostTag",
back_populates="tag",
)
```
#### Association Object 사용 예시
```python
# 생성 - 추가 데이터 포함
post = Post(title="Python 팁")
tag = Tag(name="python")
post_tag = PostTag(tag=tag, created_by=current_user.id)
post.post_tags.append(post_tag)
session.add(post)
session.commit()
# 조회 - 추가 데이터 접근
for pt in post.post_tags:
print(f"Tag: {pt.tag.name}, Added: {pt.created_at}")
```
### 7.4 관계 비교 표
| 관계 | FK 위치 | 부모 타입 | 자식 타입 | 특수 설정 |
|------|---------|----------|----------|----------|
| **1:1** | 자식 | `Mapped["Child"]` | `Mapped["Parent"]` | `uselist=False`, `unique=True` |
| **1:N** | 자식 | `Mapped[list["Child"]]` | `Mapped["Parent"]` | 기본 설정 |
| **N:M** | 중간 테이블 | `Mapped[list["Other"]]` | `Mapped[list["Other"]]` | `secondary=` |
---
## 8. ORM 사용법
### 8.1 생성 (Create)
```python
# ─────────────────────────────────────────────────────
# 방법 1: relationship으로 연결
# ─────────────────────────────────────────────────────
project = Project(store_name="카페")
# append로 추가
project.images.append(Image(filename="logo.png"))
project.images.append(Image(filename="photo.jpg"))
session.add(project) # project만 add해도 images도 저장됨
session.commit()
# ─────────────────────────────────────────────────────
# 방법 2: 생성자에서 한번에
# ─────────────────────────────────────────────────────
project = Project(
store_name="카페",
images=[
Image(filename="logo.png"),
Image(filename="photo.jpg"),
]
)
session.add(project)
session.commit()
# ─────────────────────────────────────────────────────
# 방법 3: FK 직접 지정
# ─────────────────────────────────────────────────────
project = Project(store_name="카페")
session.add(project)
session.flush() # ID 생성
image = Image(project_id=project.id, filename="logo.png")
session.add(image)
session.commit()
```
### 8.2 조회 (Read)
```python
# ─────────────────────────────────────────────────────
# 기본 조회
# ─────────────────────────────────────────────────────
project = session.get(Project, 1)
print(project.images) # lazy 설정에 따라 로딩
# ─────────────────────────────────────────────────────
# Eager Loading (명시적)
# ─────────────────────────────────────────────────────
from sqlalchemy.orm import selectinload, joinedload
# selectinload - 1:N에 권장
stmt = (
select(Project)
.options(selectinload(Project.images))
.where(Project.id == 1)
)
project = session.scalar(stmt)
# joinedload - 1:1, N:1에 권장
stmt = (
select(Image)
.options(joinedload(Image.project))
.where(Image.id == 1)
)
image = session.scalar(stmt)
# 중첩 로딩
stmt = (
select(Project)
.options(
selectinload(Project.lyrics)
.selectinload(Lyric.songs)
)
)
# ─────────────────────────────────────────────────────
# 필터링과 함께
# ─────────────────────────────────────────────────────
# 특정 조건의 자식을 가진 부모
stmt = (
select(Project)
.join(Project.images)
.where(Image.filename.like("%.png"))
.distinct()
)
# 자식 개수와 함께
from sqlalchemy import func
stmt = (
select(Project, func.count(Image.id).label("image_count"))
.join(Project.images, isouter=True)
.group_by(Project.id)
)
```
### 8.3 수정 (Update)
```python
# ─────────────────────────────────────────────────────
# 자식 추가
# ─────────────────────────────────────────────────────
project = session.get(Project, 1)
project.images.append(Image(filename="new.png"))
session.commit()
# ─────────────────────────────────────────────────────
# 자식 수정
# ─────────────────────────────────────────────────────
project = session.get(Project, 1)
project.images[0].filename = "updated.png"
session.commit()
# ─────────────────────────────────────────────────────
# 부모 변경 (relationship 사용)
# ─────────────────────────────────────────────────────
image = session.get(Image, 1)
new_project = session.get(Project, 2)
image.project = new_project # FK 자동 업데이트
session.commit()
# ─────────────────────────────────────────────────────
# 부모 변경 (FK 직접 사용)
# ─────────────────────────────────────────────────────
image = session.get(Image, 1)
image.project_id = 2
session.commit()
```
### 8.4 삭제 (Delete)
```python
# ─────────────────────────────────────────────────────
# 부모 삭제 (cascade 동작)
# ─────────────────────────────────────────────────────
# cascade="all, delete-orphan" 설정 시
project = session.get(Project, 1)
session.delete(project)
session.commit()
# → 모든 images도 삭제됨
# ─────────────────────────────────────────────────────
# 자식만 삭제
# ─────────────────────────────────────────────────────
image = session.get(Image, 1)
session.delete(image)
session.commit()
# ─────────────────────────────────────────────────────
# 부모에서 분리 (delete-orphan 시 삭제됨)
# ─────────────────────────────────────────────────────
project = session.get(Project, 1)
image = project.images[0]
project.images.remove(image)
session.commit()
# → delete-orphan이면 image도 DB에서 삭제
# ─────────────────────────────────────────────────────
# 자식 전체 교체
# ─────────────────────────────────────────────────────
project = session.get(Project, 1)
project.images = [Image(filename="new1.png"), Image(filename="new2.png")]
session.commit()
# → 기존 images는 orphan이 되어 삭제됨 (delete-orphan 시)
```
---
## 9. 실무 패턴
### 9.1 표준 모델 템플릿
```python
from datetime import datetime
from typing import List
from sqlalchemy import String, Text, ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class TimestampMixin:
"""생성/수정 시간 공통 믹스인"""
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(
default=func.now(),
onupdate=func.now()
)
class Project(TimestampMixin, Base):
__tablename__ = "project"
id: Mapped[int] = mapped_column(primary_key=True)
store_name: Mapped[str] = mapped_column(String(255))
task_id: Mapped[str] = mapped_column(String(36), unique=True, index=True)
# 1:N 관계들
images: Mapped[List["Image"]] = relationship(
"Image",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
order_by="Image.created_at.desc()",
)
lyrics: Mapped[List["Lyric"]] = relationship(
"Lyric",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Project(id={self.id}, store_name='{self.store_name}')>"
class Image(TimestampMixin, Base):
__tablename__ = "image"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(
ForeignKey("project.id", ondelete="CASCADE"),
index=True,
)
original_filename: Mapped[str] = mapped_column(String(255))
stored_filename: Mapped[str] = mapped_column(String(255))
url: Mapped[str] = mapped_column(Text)
# N:1 관계
project: Mapped["Project"] = relationship(
"Project",
back_populates="images",
)
def __repr__(self) -> str:
return f"<Image(id={self.id}, filename='{self.original_filename}')>"
```
### 9.2 서비스 레이어 패턴
```python
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
class ProjectService:
def __init__(self, session: Session):
self.session = session
def create_with_images(
self,
store_name: str,
task_id: str,
image_data: list[dict]
) -> Project:
"""프로젝트와 이미지를 함께 생성"""
project = Project(
store_name=store_name,
task_id=task_id,
images=[Image(**data) for data in image_data],
)
self.session.add(project)
self.session.commit()
self.session.refresh(project)
return project
def get_with_images(self, project_id: int) -> Project | None:
"""프로젝트와 이미지를 함께 조회"""
stmt = (
select(Project)
.options(selectinload(Project.images))
.where(Project.id == project_id)
)
return self.session.scalar(stmt)
def get_by_task_id(self, task_id: str) -> Project | None:
"""task_id로 조회"""
stmt = (
select(Project)
.options(selectinload(Project.images))
.where(Project.task_id == task_id)
)
return self.session.scalar(stmt)
def add_image(self, project_id: int, image: Image) -> Image:
"""기존 프로젝트에 이미지 추가"""
project = self.session.get(Project, project_id)
if not project:
raise ValueError("Project not found")
project.images.append(image)
self.session.commit()
self.session.refresh(image)
return image
def delete(self, project_id: int) -> bool:
"""프로젝트 삭제 (이미지도 cascade 삭제)"""
project = self.session.get(Project, project_id)
if not project:
return False
self.session.delete(project)
self.session.commit()
return True
```
### 9.3 FastAPI 엔드포인트 패턴
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
router = APIRouter(prefix="/projects", tags=["projects"])
@router.post("/", response_model=ProjectResponse)
def create_project(
data: ProjectCreate,
session: Session = Depends(get_session),
):
service = ProjectService(session)
project = service.create_with_images(
store_name=data.store_name,
task_id=data.task_id,
image_data=[img.model_dump() for img in data.images],
)
return project
@router.get("/{project_id}", response_model=ProjectWithImagesResponse)
def get_project(
project_id: int,
session: Session = Depends(get_session),
):
service = ProjectService(session)
project = service.get_with_images(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.post("/{project_id}/images", response_model=ImageResponse)
def add_image(
project_id: int,
data: ImageCreate,
session: Session = Depends(get_session),
):
service = ProjectService(session)
image = Image(**data.model_dump())
return service.add_image(project_id, image)
```
---
## 10. Quick Reference
### 10.1 relationship 정의 체크리스트
```python
# ✅ 부모 (1 쪽)
children: Mapped[list["Child"]] = relationship(
"Child", # 1. 대상 클래스
back_populates="parent", # 2. 반대편 속성명
cascade="all, delete-orphan", # 3. 삭제 정책
lazy="selectin", # 4. 로딩 전략
)
# ✅ 자식 (N 쪽)
parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) # FK 필수!
parent: Mapped["Parent"] = relationship(
"Parent", # 1. 대상 클래스
back_populates="children", # 2. 반대편 속성명
)
```
### 10.2 관계별 빠른 참조
```python
# 1:1
# 부모: uselist=False
# 자식 FK: unique=True
# 1:N
# 부모: list["Child"]
# 자식: "Parent"
# N:M
# secondary=association_table
# 또는 Association Object 패턴
```
### 10.3 자주 쓰는 옵션 조합
```python
# 기본 1:N (부모 측)
cascade="all, delete-orphan", lazy="selectin"
# 기본 N:1 (자식 측)
# 옵션 없이 back_populates만
# 1:1 (부모 측)
uselist=False, cascade="all, delete-orphan"
# 읽기 전용 관계
viewonly=True
# 복수 FK
foreign_keys=[column]
```
### 10.4 흔한 실수
```python
# ❌ 잘못된 예
class Parent(Base):
children = relationship("Child") # Mapped 타입 힌트 없음
class Child(Base):
parent_id = Column(Integer) # ForeignKey 없음
parent = relationship("Parent", back_populates="childs") # 오타
# ✅ 올바른 예
class Parent(Base):
children: Mapped[list["Child"]] = relationship("Child", back_populates="parent")
class Child(Base):
parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id"))
parent: Mapped["Parent"] = relationship("Parent", back_populates="children")
```
---
## 부록: 참고 자료
- [SQLAlchemy 2.0 공식 문서](https://docs.sqlalchemy.org/en/20/)
- [SQLAlchemy Relationship Configuration](https://docs.sqlalchemy.org/en/20/orm/relationships.html)
- [SQLAlchemy Basic Relationship Patterns](https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html)