# 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)
