ORM - 관계 매핑

Kjjedd·2026년 1월 26일

ORM

목록 보기
7/8
post-thumbnail

ORM 관계 매핑 (1:1, 1:N, N:M)

ORM을 쓰면 “테이블 사이 관계(외래키)”를 “클래스 사이 참조(객체 그래프)”로 옮겨 담게 된다.
이 과정을 관계 매핑(Relationship Mapping) 이라 부른다. 😺


1. 관계 매핑(Relationship Mapping)의 의미 🧩

  • Relationship(관계): DB에서 테이블 간 연결(외래키)로 표현되는 구조다
  • Mapping(매핑): A를 B로 대응시키는 변환이다

즉, DB의 “숫자(외래키)”로 된 관계를 애플리케이션의 “참조(객체)”로 바꾸는 작업이 관계 매핑이다.

비유로 이해하기 🧠

DB는 주민등록등본처럼 관계를 “ID로 기록”한다.
ORM은 가족처럼 관계를 “서로를 직접 아는 참조”로 표현한다.

[DB 세계]                            [ORM 세계]
users.address_id = 3                 user.address -> Address 객체
(숫자로 연결)                         (객체 참조로 연결)

결국 ORM이 하는 일은 딱 이거다.

  • DB: user.address_id = 3 (정수)
  • ORM: user.address = Address(...) (객체)

핵심 포인트 🔥

  • 관계 매핑은 “데이터 구조”의 변환이 아니라 “탐색 방식” 의 변환이다
  • SQL의 JOIN 사고방식이, 점(.)으로 이어지는 객체 그래프 탐색으로 바뀐다

2. 관계의 종류 한눈에 보기 🗺️

관계는 결국 “한 행이 다른 테이블의 행을 몇 개까지 가질 수 있나”로 정리된다.

+------+------------------------+------------------------+
| 구분  | 의미                    | 예시                     |
+------+------------------------+------------------------+
| 1:1  | 하나 ↔ 하나              | 사용자 ↔ 프로필            |
| 1:N  | 하나 ↔ 여러 개            | 팀 ↔ 멤버들               |
| N:M  | 여러 개 ↔ 여러 개         | 학생들 ↔ 수업들            |
+------+------------------------+------------------------+

그리고 관계마다 “외래키가 어디에 있는가”가 거의 모든 설계를 결정한다.

  • 1:N은 외래키가 무조건 N쪽에 있다
  • 1:1은 외래키가 한쪽에 있지만, UNIQUE로 1:1을 강제한다
  • N:M은 중간 테이블(연결 테이블)이 없으면 구현이 안 된다 (뒤에서 다룸)

3. 1:N 관계 (가장 흔한 관계) 👥

1:N은 실무에서 제일 자주 만난다.
“팀 하나에 멤버 여러 명”, “게시글 하나에 댓글 여러 개” 같은 구조다.

3-1. DB 테이블 구조

외래키는 항상 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를 들고 있음)

왜 외래키는 N쪽에 있나? 🤔

  • “한 팀에 여러 멤버”이므로 멤버 각각이 “내 팀이 누구인지”를 들고 있는 편이 자연스럽다
  • 반대로 팀이 멤버들의 id를 컬럼으로 들고 있으면 컬럼이 무한히 늘어나거나 별도 구조가 필요해진다

3-2. ORM 관점에서 관계를 읽는 법

1:N을 ORM으로 옮기면 “참조 방향”이 2개가 생긴다.

  • N → 1: 멤버는 팀을 1개 가진다 (단일 객체)
  • 1 → N: 팀은 멤버를 여러 개 가진다 (리스트/컬렉션)
Team (1)  ----<  Member (N)

Team.members  : [Member, Member, ...]   (컬렉션)
Member.team   : Team                    (단일)

여기서 중요한 개념 하나 추가한다. 🧠

관계의 주인(Ownership) / FK를 가진 쪽이 주도권을 가진다

  • DB 기준으로는 외래키를 가진 쪽이 “관계를 실제로 저장하는 쪽”이다
  • 즉 1:N에서는 Memberteam_id(FK)를 갖기 때문에 관계 저장의 중심은 Member 쪽이다
  • Team.members는 “보기 편하게 제공하는 역방향 탐색 통로”에 가깝다

3-3. 코드 스크립트 (SQLAlchemy 스타일)

아래 코드는 “양방향 탐색”이 가능한 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})>"

back_populates는 왜 필요한가? 🔁

  • 양쪽 관계가 “서로 같은 관계의 양면”임을 알려준다
  • 그래서 한쪽에 멤버를 추가하면 다른쪽 탐색에서도 자연스럽게 이어질 수 있다(동기화에 도움)

실제 사용 흐름

# 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을 쓰는 이유가 확 드러난다.

  • DB에서는 JOIN을 써야 이어지는 데이터를, ORM에서는 점(.)으로 이어서 탐색한다
  • 즉, “관계”가 “탐색”이 된다

4. 1:1 관계 🧍‍♂️🪪

1:1은 “한 사용자는 하나의 프로필만 가진다” 같은 구조다.
1:N보다 덜 흔하지만, 계정/프로필, 사용자/설정 같은 데서 자주 쓴다.

4-1. DB 테이블 구조

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에 두 번 들어가면 안 되기 때문)
  • 외래키만 보면 1:1과 1:N은 형태가 비슷하다
  • 차이를 만드는 건 UNIQUE
  • 즉 “DB에서 1:1은 제약조건으로 만든다”가 핵심이다

5. N:M 관계 (Many-to-Many) 🔁

N:M 관계는 “여러 개 ↔ 여러 개”가 서로 연결되는 구조다.
대표적으로 학생 ↔ 수업, 유저 ↔ 역할(Role), 게시글 ↔ 태그 같은 케이스가 여기에 해당한다.


5-1. N:M은 왜 “직접” 연결이 안 되나? 🤔

관계형 DB에서 외래키(FK)는 한 컬럼에 “하나의 값”만 들어간다.
그런데 N:M을 한 테이블에 억지로 넣으면 이런 형태가 된다.

students
+------------+---------+------------------+
| student_id | name    | course_ids       |
+------------+---------+------------------+
| 1          | kjjedd  | 1,2,3            |
+------------+---------+------------------+

문제:
- course_ids 가 "정규화"를 깨뜨린다 (원자값 X)
- JOIN / 검색 / 인덱싱 / 무결성 관리가 지옥이 된다

그래서 N:M은 반드시 중간 테이블(연결 테이블, junction table)이 필요하다.
이 중간 테이블이 “외래키 2개”로 관계를 저장하는 역할을 한다.


5-2. DB 테이블 구조 (연결 테이블 필수) 🧱

학생과 수업 예시로 보자.

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

여기서 중요한 포인트가 있다.

  • N:M의 관계 저장 주인은 “연결 테이블”이다
  • students 와 courses는 서로를 직접 FK로 들고 있지 않다
  • 관계는 enrollments가 “두 FK의 쌍”으로 기록한다

5-3. ORM에서 N:M을 읽는 법 (객체 그래프 관점) 🧠

ORM으로 옮기면 이렇게 된다.

Student (N)  ----<  Enrollment  >----  Course (M)

Student.courses  : [Course, Course, ...]
Course.students  : [Student, Student, ...]

다만 ORM 구현 방식이 2가지로 갈린다.

방식 언제 쓰나 핵심
단순 연결(secondary) 연결 테이블에 “추가 데이터”가 없을 때 연결 테이블은 Table 객체로만 둔다
연결 테이블을 엔티티로(Association Object) 수강일/권한레벨/주문수량처럼 “관계 자체에 데이터”가 있을 때 Enrollment를 클래스(모델)로 만든다

5-4. (A) 단순 N:M 매핑: secondary 방식 ✅

연결 테이블에 student_id / course_id 외에 아무것도 없다면 secondary가 가장 깔끔하다.

핵심 개념

  • 연결 테이블은 “두 테이블을 이어주는 다리”일 뿐이라 모델 클래스로 만들 필요가 없다
  • ORM은 relationship(..., 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에서는 점(.)과 리스트로 끝난다.


5-5. (B) “관계 자체에 데이터가 있는” N:M 매핑: Association Object ✅✅

현실 세계의 N:M은 거의 항상 관계에 데이터가 붙는다.
예를 들면:

  • 수강 신청일(enrolled_at)
  • 성적(grade)
  • 유저-역할에서 역할 부여 시각(granted_at)
  • 주문-상품에서 수량(quantity), 단가(price)

이런 경우 secondary로는 한계가 있다. 이유는 간단하다.
secondary는 “관계의 존재”만 표현하지, 관계의 속성까지 예쁘게 다루기 어렵다.
그래서 연결 테이블을 “모델 클래스”로 승격시킨다.

5-5-1. DB 구조 (추가 컬럼 포함) 🧾

enrollments
+------------+-----------+---------------------+-------+
| student_id | course_id | enrolled_at         | grade |
+------------+-----------+---------------------+-------+
| 1          | 10        | 2026-01-01 10:00:00 | A+    |
+------------+-----------+---------------------+-------+

PK는 보통 (student_id, course_id) 복합키를 쓴다
(한 학생이 같은 수업을 중복 신청하면 안 되니까)

5-5-2. ORM 모델링 (Enrollment를 클래스화) 🧩

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

이 구조의 핵심

  • 겉으로는 N:M이지만 ORM 내부에서는 Student - Enrollment - Course로 쪼개져서 1:N + N:1로 관리된다
  • Enrollment는 더 이상 “단순 연결”이 아니라, 의미 있는 도메인 엔티티가 된다

5-5-3. 사용 흐름 (관계에 데이터까지 넣기) 🧪

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

6. 관계 설정 시 실전 주의사항 ⚠️

6-1. cascade를 잘못 쓰면 데이터가 “연쇄 삭제”된다 🧨

cascade는 “부모를 삭제할 때 자식도 삭제할지” 같은 동작을 결정한다.
특히 Association Object에서 delete-orphan은 강력하다.

  • delete-orphan: 부모 컬렉션에서 빠진 자식을 DB에서도 삭제한다
  • 실무에서 실수하면 의도치 않은 데이터 삭제가 날 수 있다

권장 감각은 이렇다.

관계 cascade 추천 감각 이유
Team - Member (1:N) all, delete-orphan (상황 따라) 멤버가 팀에 종속이면 합리적이다
Student - Enrollment - Course Enrollment 쪽에만 delete-orphan 고려 수업(Course)은 독립 엔티티인 경우가 많다

6-2. back_populates는 “양방향 동기화 힌트”다 🔁

back_populates는 양쪽 relationship이 같은 관계라는 걸 ORM에게 알려준다.
이걸 해두면 “한쪽에 추가했을 때 다른쪽에서도 자연스럽게 이어지는” 느낌을 얻을 수 있다.

  • Student.courses.append(course) 하면
  • Course.students에도 Student가 보이는 식의 감각

단, “DB 저장의 주인”은 항상 컬럼(FK) / 연결 테이블이 가진다.
ORM에서 보기 편하다고 해서 DB 구조가 바뀌는 건 아니다.


6-3. N+1 문제는 관계 매핑과 항상 같이 온다 🪓

관계를 점(.)으로 탐색하는 순간, ORM은 필요하면 추가 쿼리를 날린다.
반복문 + 관계 탐색을 섞으면 쿼리가 폭발할 수 있다.

# students = session.query(Student).all()
# for s in students:
#     print(s.courses)   # 여기서 매번 SELECT가 나갈 수 있다 (설정에 따라)

즉, 관계 매핑을 잘하는 것과 별개로,
“어떤 데이터를 한 번에 로딩할지” 전략이 반드시 따라온다 (eager loading / selectin 등).
이건 ORM 실무 난이도를 올리는 핵심 포인트다.


7. 관계 종류 비교 정리 (1:1 / 1:N / N:M) 📌

관계 DB에서 핵심 ORM에서 핵심 반환 형태
1:1 FK + UNIQUE uselist=False 단일 객체
1:N FK는 N쪽 relationship 양방향 1쪽: 리스트 / N쪽: 단일
N:M 연결 테이블 필수 secondary 또는 Association Object 양쪽 모두 리스트(또는 enrollments 컬렉션)

8. 최종 정리 ✅

  • 관계 매핑은 DB의 외래키 관계를 객체 참조로 바꾸는 과정이다
  • 이 변환은 “데이터 변환”이 아니라 탐색 방식의 변환이다 (JOIN → 객체 그래프)
  • 1:N은 실무 최빈도, FK는 무조건 N쪽이다
  • 1:1은 UNIQUE로 강제해야 진짜 1:1이 된다
  • N:M은 중간 테이블이 없으면 불가능하다
  • N:M 구현은
    • 관계에 데이터 없으면: secondary가 깔끔하다
    • 관계에 데이터 있으면: Association Object(연결 테이블 클래스화)가 정답이다
  • 관계 매핑을 시작하는 순간, N+1 같은 쿼리 전략 문제가 같이 따라온다
profile
Gongbuhaja

0개의 댓글