# 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) # - 자동 설정! 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"" 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"" ``` ### 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)