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

44 KiB

SQLAlchemy Relationship 완벽 가이드

SQLAlchemy 2.0+ / Python 3.10+ 기준


목차

  1. 개요
  2. 기본 개념
  3. relationship 정의 문법
  4. 부모와 자식에서의 정의 차이
  5. FK 필드 vs back_populates
  6. relationship 옵션 상세
  7. 관계별 정의 방법 (1:1, 1:N, N:M)
  8. ORM 사용법
  9. 실무 패턴
  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 속성 생성
# 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 관계 방향

# 단방향 (Unidirectional)
# - 한쪽에서만 relationship 정의
# - 반대쪽 접근 불가

# 양방향 (Bidirectional)
# - 양쪽 모두 relationship 정의
# - back_populates로 연결
# - 실무 권장 방식

2.3 기본 예제 (가장 간단한 형태)

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 기본 문법

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"
# 최소한의 정의 (단방향)
project: Mapped["Project"] = relationship("Project")

3.3 권장 항목

항목 설명 예시
back_populates 반대편 relationship 속성명 back_populates="images"
# 권장하는 정의 (양방향)
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 부모 클래스 정의

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 자식 클래스 정의

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 두 가지 접근 방식

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 필드 직접 사용

# ─────────────────────────────────────────────────────
# 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) 사용

# ─────────────────────────────────────────────────────
# 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 양방향 동기화 동작

# 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 공식 권장 사용 방식

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 필수/권장 옵션

첫 번째 인자: 대상 클래스

# 문자열 (Forward Reference) - 권장
relationship("Project")

# 클래스 직접 참조 (순환 import 주의)
relationship(Project)

back_populates (양방향 연결)

# 부모 측
images: Mapped[list["Image"]] = relationship(
    "Image", 
    back_populates="project"  # Image.project와 연결
)

# 자식 측
project: Mapped["Project"] = relationship(
    "Project", 
    back_populates="images"  # Project.images와 연결
)

6.2 cascade 옵션

자식 객체의 생명주기 관리. 부모 측에서 정의합니다.

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 제외)

일반적인 조합

# 1. 기본값 (자동 적용)
cascade="save-update, merge"

# 2. 부모 삭제 시 자식도 삭제
cascade="all, delete"

# 3. 부모에서 분리 시에도 삭제 (가장 엄격)
cascade="all, delete-orphan"

# 4. 삭제 방지 (자식은 독립적)
cascade="save-update, merge"  # delete 없음

cascade 동작 예시

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 옵션 (로딩 전략)

관계 데이터를 언제/어떻게 로딩할지 결정합니다.

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)

로딩 전략 예시

# 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 옵션

반환 타입을 리스트/단일 객체로 지정합니다.

# 기본값: True (리스트)
images: Mapped[list["Image"]] = relationship("Image", uselist=True)

# 1:1 관계에서 단일 객체
profile: Mapped["Profile"] = relationship("Profile", uselist=False)

6.5 foreign_keys 옵션

여러 FK가 있을 때 명시적 지정이 필요합니다.

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 옵션

복잡한 조인 조건을 직접 정의합니다.

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 옵션

자식 컬렉션의 기본 정렬 순서를 지정합니다.

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 옵션

읽기 전용 관계로 지정합니다 (쓰기 비활성화).

# 통계/조회용 관계
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)   │
└─────────────┘         └─────────────┘

기본 예제

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 핵심 포인트

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

사용 예시

# 생성
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)   │
└─────────────┘         └─────────────┘

기본 예제

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",
    )

사용 예시

# 생성 - 방법 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 (단순 연결)

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 사용 예시

# 생성
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 (추가 데이터 필요 시)

중간 테이블에 추가 컬럼이 필요한 경우 사용합니다.

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 사용 예시

# 생성 - 추가 데이터 포함
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)

# ─────────────────────────────────────────────────────
# 방법 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)

# ─────────────────────────────────────────────────────
# 기본 조회
# ─────────────────────────────────────────────────────
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)

# ─────────────────────────────────────────────────────
# 자식 추가
# ─────────────────────────────────────────────────────
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)

# ─────────────────────────────────────────────────────
# 부모 삭제 (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 표준 모델 템플릿

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 서비스 레이어 패턴

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 엔드포인트 패턴

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 정의 체크리스트

# ✅ 부모 (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 관계별 빠른 참조

# 1:1
# 부모: uselist=False
# 자식 FK: unique=True

# 1:N
# 부모: list["Child"]
# 자식: "Parent"

# N:M
# secondary=association_table
# 또는 Association Object 패턴

10.3 자주 쓰는 옵션 조합

# 기본 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 흔한 실수

# ❌ 잘못된 예
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")

부록: 참고 자료