
ORM을 쓰면 “테이블 사이 관계(외래키)”를 “클래스 사이 참조(객체 그래프)”로 옮겨 담게 된다.
이 과정을 관계 매핑(Relationship Mapping) 이라 부른다. 😺
즉, DB의 “숫자(외래키)”로 된 관계를 애플리케이션의 “참조(객체)”로 바꾸는 작업이 관계 매핑이다.
DB는 주민등록등본처럼 관계를 “ID로 기록”한다.
ORM은 가족처럼 관계를 “서로를 직접 아는 참조”로 표현한다.
[DB 세계] [ORM 세계]
users.address_id = 3 user.address -> Address 객체
(숫자로 연결) (객체 참조로 연결)
결국 ORM이 하는 일은 딱 이거다.
user.address_id = 3 (정수)user.address = Address(...) (객체)관계는 결국 “한 행이 다른 테이블의 행을 몇 개까지 가질 수 있나”로 정리된다.
+------+------------------------+------------------------+
| 구분 | 의미 | 예시 |
+------+------------------------+------------------------+
| 1:1 | 하나 ↔ 하나 | 사용자 ↔ 프로필 |
| 1:N | 하나 ↔ 여러 개 | 팀 ↔ 멤버들 |
| N:M | 여러 개 ↔ 여러 개 | 학생들 ↔ 수업들 |
+------+------------------------+------------------------+
그리고 관계마다 “외래키가 어디에 있는가”가 거의 모든 설계를 결정한다.
1:N은 실무에서 제일 자주 만난다.
“팀 하나에 멤버 여러 명”, “게시글 하나에 댓글 여러 개” 같은 구조다.
외래키는 항상 N쪽(많은 쪽)에 위치한다.
이거 하나만 기억해도 절반은 먹고 들어간다.
[1] teams (1쪽)
+---------+-----------+
| team_id | name |
+---------+-----------+
| 1 | 개발팀 |
+---------+-----------+
[N] members (N쪽)
+-----------+----------+---------+
| member_id | name | team_id |
+-----------+----------+---------+
| 1 | 임제프 | 1 |
| 2 | 김철수 | 1 |
+-----------+----------+---------+
관계:
members.team_id ---> teams.team_id
(N쪽이 1쪽의 PK를 들고 있음)
1:N을 ORM으로 옮기면 “참조 방향”이 2개가 생긴다.
Team (1) ----< Member (N)
Team.members : [Member, Member, ...] (컬렉션)
Member.team : Team (단일)
여기서 중요한 개념 하나 추가한다. 🧠
team_id(FK)를 갖기 때문에 관계 저장의 중심은 Member 쪽이다Team.members는 “보기 편하게 제공하는 역방향 탐색 통로”에 가깝다아래 코드는 “양방향 탐색”이 가능한 1:N 매핑 예시다.
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
class Team(Base):
__tablename__ = "teams"
team_id = Column(Integer, primary_key=True)
name = Column(String(100))
# 1 -> N : 팀이 멤버들을 리스트로 가진다
members = relationship("Member", back_populates="team")
def __repr__(self):
return f"<Team(name={self.name})>"
class Member(Base):
__tablename__ = "members"
member_id = Column(Integer, primary_key=True)
name = Column(String(100))
# N쪽에 FK가 존재한다 (이게 관계의 저장 중심)
team_id = Column(Integer, ForeignKey("teams.team_id"))
# N -> 1 : 멤버는 팀을 하나 가진다
team = relationship("Team", back_populates="members")
def __repr__(self):
return f"<Member(name={self.name})>"
# dev_team = Team(name="개발팀")
# jay = Member(name="kjjedd", team=dev_team)
# chulsoo = Member(name="김철수", team=dev_team)
# jay.team.name -> "개발팀" (N -> 1 탐색)
# dev_team.members[0].name -> "kjjedd" (1 -> N 탐색)
여기서 ORM을 쓰는 이유가 확 드러난다.
1:1은 “한 사용자는 하나의 프로필만 가진다” 같은 구조다.
1:N보다 덜 흔하지만, 계정/프로필, 사용자/설정 같은 데서 자주 쓴다.
1:1은 외래키만 두면 1:N이 되어버린다.
그래서 UNIQUE 제약조건으로 1:1을 강제해야 한다.
users
+---------+-------------------+
| user_id | email |
+---------+-------------------+
| 1 | jay@gmail.com |
+---------+-------------------+
profiles
+------------+----------------------+---------+
| profile_id | bio | user_id |
+------------+----------------------+---------+
| 10 | 백엔드 개발자 | 1 |
+------------+----------------------+---------+
관계:
profiles.user_id ---> users.user_id
중요:
profiles.user_id 에 UNIQUE를 걸어야 1:1이 된다
(한 user_id가 profiles에 두 번 들어가면 안 되기 때문)
N:M 관계는 “여러 개 ↔ 여러 개”가 서로 연결되는 구조다.
대표적으로 학생 ↔ 수업, 유저 ↔ 역할(Role), 게시글 ↔ 태그 같은 케이스가 여기에 해당한다.
관계형 DB에서 외래키(FK)는 한 컬럼에 “하나의 값”만 들어간다.
그런데 N:M을 한 테이블에 억지로 넣으면 이런 형태가 된다.
students
+------------+---------+------------------+
| student_id | name | course_ids |
+------------+---------+------------------+
| 1 | kjjedd | 1,2,3 |
+------------+---------+------------------+
문제:
- course_ids 가 "정규화"를 깨뜨린다 (원자값 X)
- JOIN / 검색 / 인덱싱 / 무결성 관리가 지옥이 된다
그래서 N:M은 반드시 중간 테이블(연결 테이블, junction table)이 필요하다.
이 중간 테이블이 “외래키 2개”로 관계를 저장하는 역할을 한다.
학생과 수업 예시로 보자.
students
+------------+---------+
| student_id | name |
+------------+---------+
| 1 | 신짱구 |
| 2 | 김철수 |
+------------+---------+
courses
+-----------+------------------+
| course_id | title |
+-----------+------------------+
| 10 | Python 기초 |
| 20 | 데이터베이스 개론 |
+-----------+------------------+
enrollments (연결 테이블)
+------------+-----------+
| student_id | course_id |
+------------+-----------+
| 1 | 10 |
| 1 | 20 |
| 2 | 10 |
+------------+-----------+
관계:
enrollments.student_id ---> students.student_id
enrollments.course_id ---> courses.course_id
여기서 중요한 포인트가 있다.
ORM으로 옮기면 이렇게 된다.
Student (N) ----< Enrollment >---- Course (M)
Student.courses : [Course, Course, ...]
Course.students : [Student, Student, ...]
다만 ORM 구현 방식이 2가지로 갈린다.
| 방식 | 언제 쓰나 | 핵심 |
|---|---|---|
| 단순 연결(secondary) | 연결 테이블에 “추가 데이터”가 없을 때 | 연결 테이블은 Table 객체로만 둔다 |
| 연결 테이블을 엔티티로(Association Object) | 수강일/권한레벨/주문수량처럼 “관계 자체에 데이터”가 있을 때 | Enrollment를 클래스(모델)로 만든다 |
연결 테이블에 student_id / course_id 외에 아무것도 없다면 secondary가 가장 깔끔하다.
핵심 개념
Python / SQLAlchemy 예시
from sqlalchemy import Column, Integer, String, ForeignKey, Table
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
# 연결 테이블은 클래스가 아니라 Table 객체로 만든다
enrollments = Table(
"enrollments",
Base.metadata,
Column("student_id", Integer, ForeignKey("students.student_id"), primary_key=True),
Column("course_id", Integer, ForeignKey("courses.course_id"), primary_key=True),
)
class Student(Base):
__tablename__ = "students"
student_id = Column(Integer, primary_key=True)
name = Column(String(100))
# N:M (학생 -> 수업)
courses = relationship(
"Course",
secondary=enrollments,
back_populates="students",
)
def __repr__(self):
return f"Student(name={self.name})
class Course(Base):
__tablename__ = "courses"
course_id = Column(Integer, primary_key=True)
title = Column(String(100))
# N:M (수업 -> 학생)
students = relationship(
"Student",
secondary=enrollments,
back_populates="courses",
)
def __repr__(self):
return f"Course(title={self.title})"
사용 흐름
# jay = Student(name="kjjedd")
# python_course = Course(title="Python 기초")
# db_course = Course(title="DB 개론")
# 리스트에 추가하는 느낌으로 연결이 된다
# jay.courses.append(python_course)
# jay.courses.append(db_course)
# python_course.students 로 역방향 탐색도 된다
# python_course.students -> [Student(...), ...]
이 방식의 장점은 “사용자 경험”이 미쳤다는 점이다.
DB에서는 enrollments를 JOIN 해야 하지만, ORM에서는 점(.)과 리스트로 끝난다.
현실 세계의 N:M은 거의 항상 관계에 데이터가 붙는다.
예를 들면:
이런 경우 secondary로는 한계가 있다. 이유는 간단하다.
secondary는 “관계의 존재”만 표현하지, 관계의 속성까지 예쁘게 다루기 어렵다.
그래서 연결 테이블을 “모델 클래스”로 승격시킨다.
enrollments
+------------+-----------+---------------------+-------+
| student_id | course_id | enrolled_at | grade |
+------------+-----------+---------------------+-------+
| 1 | 10 | 2026-01-01 10:00:00 | A+ |
+------------+-----------+---------------------+-------+
PK는 보통 (student_id, course_id) 복합키를 쓴다
(한 학생이 같은 수업을 중복 신청하면 안 되니까)
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime
Base = declarative_base()
class Enrollment(Base):
__tablename__ = "enrollments"
# 복합 PK (관계 자체가 고유해야 하니까)
student_id = Column(Integer, ForeignKey("students.student_id"), primary_key=True)
course_id = Column(Integer, ForeignKey("courses.course_id"), primary_key=True)
# 관계에 붙는 추가 데이터
enrolled_at = Column(DateTime, default=datetime.now)
grade = Column(String(2))
# 양쪽 참조 (Enrollment -> Student, Course)
student = relationship("Student", back_populates="enrollments")
course = relationship("Course", back_populates="enrollments")
def __repr__(self):
return f"Enrollment(student_id={self.student_id}, course_id={self.course_id}, grade={self.grade})"
class Student(Base):
__tablename__ = "students"
student_id = Column(Integer, primary_key=True)
name = Column(String(100))
# 학생은 Enrollment 목록을 가진다 (1:N)
enrollments = relationship(
"Enrollment",
back_populates="student",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"Student(name={self.name})"
class Course(Base):
__tablename__ = "courses"
course_id = Column(Integer, primary_key=True)
title = Column(String(100))
# 수업은 Enrollment 목록을 가진다 (1:N)
enrollments = relationship(
"Enrollment",
back_populates="course",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"Course(title={self.title})"
이 구조의 핵심
# jay = Student(name="kjjedd")
# python_course = Course(title="Python 기초")
# 관계를 직접 생성한다 (Enrollment가 주인공)
# enroll = Enrollment(student=jay, course=python_course, grade="A+")
# session.add(enroll)
# session.commit()
# 조회/탐색
# for e in jeff.enrollments:
# print(e.course.title, e.grade)
# 결과:
# Python 기초 A+
cascade는 “부모를 삭제할 때 자식도 삭제할지” 같은 동작을 결정한다.
특히 Association Object에서 delete-orphan은 강력하다.
권장 감각은 이렇다.
| 관계 | cascade 추천 감각 | 이유 |
|---|---|---|
| Team - Member (1:N) | all, delete-orphan (상황 따라) | 멤버가 팀에 종속이면 합리적이다 |
| Student - Enrollment - Course | Enrollment 쪽에만 delete-orphan 고려 | 수업(Course)은 독립 엔티티인 경우가 많다 |
back_populates는 양쪽 relationship이 같은 관계라는 걸 ORM에게 알려준다.
이걸 해두면 “한쪽에 추가했을 때 다른쪽에서도 자연스럽게 이어지는” 느낌을 얻을 수 있다.
단, “DB 저장의 주인”은 항상 컬럼(FK) / 연결 테이블이 가진다.
ORM에서 보기 편하다고 해서 DB 구조가 바뀌는 건 아니다.
관계를 점(.)으로 탐색하는 순간, ORM은 필요하면 추가 쿼리를 날린다.
반복문 + 관계 탐색을 섞으면 쿼리가 폭발할 수 있다.
# students = session.query(Student).all()
# for s in students:
# print(s.courses) # 여기서 매번 SELECT가 나갈 수 있다 (설정에 따라)
즉, 관계 매핑을 잘하는 것과 별개로,
“어떤 데이터를 한 번에 로딩할지” 전략이 반드시 따라온다 (eager loading / selectin 등).
이건 ORM 실무 난이도를 올리는 핵심 포인트다.
| 관계 | DB에서 핵심 | ORM에서 핵심 | 반환 형태 |
|---|---|---|---|
| 1:1 | FK + UNIQUE | uselist=False | 단일 객체 |
| 1:N | FK는 N쪽 | relationship 양방향 | 1쪽: 리스트 / N쪽: 단일 |
| N:M | 연결 테이블 필수 | secondary 또는 Association Object | 양쪽 모두 리스트(또는 enrollments 컬렉션) |