ORM - OOP Paradigm

Kjjedd·2026년 1월 26일

ORM

목록 보기
4/8
post-thumbnail

Query와 객체지향의 패러다임 불일치

1. 패러다임(Paradigm)이란

📌 Paradigm은 '본보기/틀/사고방식' 같은 의미.
프로그래밍에서 패러다임은 문제를 바라보고 해결하는 기본 관점이다.

[같은 퍼즐, 다른 전략]
- A: 가장자리부터 맞춤
- B: 색깔별로 분류해서 맞춤

둘 다 퍼즐은 완성하지만 "접근 방식(패러다임)"이 다름

💡 즉, 패러다임은 "정답"이 아니라 사고의 프레임.
그런데 OOP와 RDB는 프레임이 아예 다르다 보니, 둘을 붙이면 여기저기서 삐걱거림이 생긴다.


2. 두 세계가 같은 '사용자(User)'를 표현하는 방식

2-1) 객체지향 세계: 데이터 + 행동(메서드) = 한 덩어리

🧠 OOP에서는 사용자를 객체로 만든 다음, 그 객체가 스스로 행동하게 만든다.

객체지향에서는 "사용자"를 이렇게 표현함

class User:
    def __init__(self, user_id, name, email):
        self.user_id = user_id   # 속성
        self.name = name         # 속성
        self.email = email       # 속성

    def change_email(self, new_email):
        self.email = new_email
        print(f"{self.name} 이메일 변경 완료: {new_email}")

# 객체 생성 + 행동 수행
jay = User(1, "kjjedd", "jay@gmail.com")
jay.change_email("new_jay@gmail.com")

✅ 핵심: 객체는 자기 상태(state)를 가지고, 자기 행동(behavior)으로 상태를 바꾼다


2-2) 관계형 DB 세계: 데이터는 테이블에 저장, 조작은 SQL로 외부에서

🗃️ RDB는 사용자를 "행(row)"으로 저장하고, 수정은 UPDATE 같은 명령(SQL)로 한다.

관계형 DB에서는 "사용자"를 이렇게 표현함

CREATE TABLE users (
    user_id INT PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100)
);

-- 데이터 삽입
INSERT INTO users VALUES (1, 'kjjedd', 'jay@gmail.com');

-- 이메일 변경 (외부에서 조작)
UPDATE users
SET email = 'new_jay@gmail.com'
WHERE user_id = 1;

✅ 핵심: DB는 데이터 중심이고, "행동"은 SQL로 외부에서 명령하는 구조.


3. 패러다임 불일치(Object-Relational Impedance Mismatch)란?

💥 OOP와 RDB는 서로 다른 철학을 갖고 있어서, 데이터가 오갈 때 매번 변환/타협이 필요

[관점 차이 요약]
OOP : "세상은 객체들의 네트워크다"  → 참조/행동/캡슐화/그래프 탐색
RDB : "세상은 테이블들의 집합이다"  → 행/컬럼/정규화/조인(JOIN)

그래서 문제가 생기는 대표 포인트들이 있고, 그중 첫 번째가 바로 정체성(Identity) 문제이다.


3-1. 정체성(Identity) 문제

OOP에서는 "메모리에서의 객체"가 정체성
DB에서는 "Primary Key(주키)"가 정체성


3-1-a) 객체지향: 메모리 주소(객체 인스턴스)가 다르면 다른 존재

OOP에서 아래 두 객체는 데이터가 같아도 기본적으로 다른 객체

class User:
    def __init__(self, user_id, name, email):
        self.user_id = user_id
        self.name = name
        self.email = email

user1 = User(1, "kjjedd", "jay@gmail.com")
user2 = User(1, "kjjedd", "jay@gmail.com")

print(user1 == user2)  # 보통 False (동등성 비교를 구현 안 하면)
print(user1 is user2)  # 무조건 False (메모리 주소가 다름)

🧠 여기서 중요한 포인트:

  • 🔹 is : "같은 객체냐?" (메모리 주소 동일)
  • 🔹 == : "동등하냐?" (보통 __eq__ 구현 여부에 따라 달라짐)

💡 즉, OOP의 기본 정체성은 "PK"가 아니라 인스턴스 자체


3-1-b) 관계형 DB: Primary Key가 같으면 같은 데이터(유일한 행)

DB에서는 user_id=1인 행은 원칙적으로 하나만 존재 가능.

user_id가 1인 행은 PK 때문에 "단 하나"만 가능

SELECT *
FROM users
WHERE user_id = 1;

✅ DB 관점에서 정체성은 "PK가 뭐냐"로 결정됨


3-1-c) 충돌 지점: DB의 1개 행 ↔ 앱 메모리의 여러 객체

[상황]
- DB에는 user_id=1 행이 딱 1개 있음
- 그런데 앱에서 조회를 2번 하면, 메모리에는 객체가 2개 생길 수 있음

DB (1 row)                APP (2 objects)
+------------------+      +---------------------+
| users            |      | userA (User #1)     |
| user_id = 1      |<---->| userB (User #1)     |
| email = ...      |      +---------------------+
+------------------+

😵 여기서 질문:
"수정은 누구를 기준으로 DB에 반영해야 하지?"

예를 들어 이런 일이 가능해진다.

- userA.email = "a@a.com" 으로 바꿈
- userB.email = "b@b.com" 으로 바꿈

둘 다 user_id=1인데 값이 다름
그럼 DB 최종값은 뭐가 돼야 함?

✅ 이게 바로 정체성 불일치에서 파생되는 대표적인 혼란.


3-1-d) 핵심 개념 추가: "한 트랜잭션(요청) 안에서는 같은 PK는 같은 객체로 취급해야 편해짐"

🧩 "같은 요청/같은 작업 단위 안에서는, 같은 PK는 같은 객체(인스턴스)로 유지하는 게 편하다"

그래서 등장하는 아이디어가 이런 것들:

  • 🗺️ Identity Map : "PK → 객체" 매핑을 캐싱해서 같은 PK면 같은 객체를 돌려줌
  • 🧾 Unit of Work : 변경사항을 추적해서 한 번에 DB에 반영

(이런 개념들을 자동으로 도와주는 대표 기술이 나중에 나오는 ORM 쪽으로 이어짐)


중간 정리 ✅

구분 객체지향(OOP) 관계형 DB(RDB) 충돌 포인트
정체성(Identity) 메모리의 객체 인스턴스 Primary Key(주키) DB는 1개 행인데 앱은 객체가 여러 개 생김

3-2. 구조적인 문제(Structural Mismatch) 🧱

객체지향은 현실을 잘게 쪼개 조립하는 방식이고, 관계형 DB는 테이블을 정규화해서 연결하는 방식이다.
이 둘이 만나면 “구조를 표현하는 방법”에서 충돌이 난다.


상황: 쇼핑몰에서 주문/고객/주소를 다뤄야 한다 🛒

✅ 객체지향 세계: 객체를 “포함(Composition)”해서 자연스럽게 탐색한다

class Address:
    def __init__(self, city: str, street: str, zipcode: str):
        self.city = city
        self.street = street
        self.zipcode = zipcode

class User:
    def __init__(self, user_id: int, name: str, address: Address):
        self.user_id = user_id
        self.name = name
        self.address = address

home = Address("서울", "강남대로 123", "06000")
jay = User(1, "kjjedd", home)

print(jay.address.city)   # "서울"

객체지향에서는 점(.)으로 그래프를 타고 내려가면 끝이다.
즉, “구조(객체의 포함 관계)”가 코드에 그대로 드러난다.

✅ 관계형 DB 세계: 테이블로 쪼개면 JOIN이 필요해진다

DB에서는 주소를 어떻게 저장해야 할까? 크게 2가지 선택지가 나온다.

방법 A) users 테이블에 주소 컬럼을 박아넣기 (정규화 X)
CREATE TABLE users (
    user_id  INT PRIMARY KEY,
    name     VARCHAR(100),
    city     VARCHAR(100),
    street   VARCHAR(200),
    zipcode  VARCHAR(10)
);
  • 👍 JOIN 필요 없음
  • 👎 주소 구조가 커질수록 컬럼 폭발
  • 👎 주소를 “재사용”하기 어려움(예: 같은 주소를 여러 유저가 공유하는 경우)
방법 B) addresses 테이블을 분리하기 (정규화 O, 대신 JOIN)
CREATE TABLE addresses (
    address_id INT PRIMARY KEY,
    city       VARCHAR(100),
    street     VARCHAR(200),
    zipcode    VARCHAR(10)
);

CREATE TABLE users (
    user_id    INT PRIMARY KEY,
    name       VARCHAR(100),
    address_id INT REFERENCES addresses(address_id)
);

SELECT u.name, a.city
FROM users u
JOIN addresses a ON u.address_id = a.address_id
WHERE u.user_id = 1;
  • 👍 주소를 독립 엔티티로 관리 가능(정규화)
  • 👎 조회할 때마다 JOIN이 자연스럽게 따라붙음

왜 이게 “구조적인 문제”인가? 🔥

객체지향의 구조는 중첩(포함)이고, DB의 구조는 분해(정규화) + 연결(JOIN)이다.
둘의 철학이 다르니 변환 비용이 발생한다.

한 번에 보기 🧩

객체지향(중첩 구조)              관계형 DB(분해 + 연결)
--------------------------------  --------------------------------
User                              users
 ├─ user_id                        ├─ user_id (PK)
 ├─ name                           ├─ name
 └─ address                        └─ address_id (FK)
     ├─ city
     ├─ street                     addresses
     └─ zipcode                     ├─ address_id (PK)
                                    ├─ city
                                    ├─ street
                                    └─ zipcode

핵심: 객체는 “안으로 품고”, DB는 “쪼개서 잇는다”. 구조가 다르니 매번 매핑이 필요하다.


3-3. 상속(Inheritance) 문제 🧬

객체지향은 상속으로 모델을 확장한다.
하지만 관계형 DB는 “상속”이라는 개념이 없다. 그래서 상속 구조를 테이블로 옮기려면 전략 선택이 필요하다.

객체지향: 상속으로 확장

class Member:
    def __init__(self, member_id: int, name: str):
        self.member_id = member_id
        self.name = name

class RegularMember(Member):
    def __init__(self, member_id: int, name: str, point: int):
        super().__init__(member_id, name)
        self.point = point

class VipMember(Member):
    def __init__(self, member_id: int, name: str, discount_rate: float):
        super().__init__(member_id, name)
        self.discount_rate = discount_rate

DB로 옮기는 대표 전략(각각 단점이 반드시 존재) ⚖️

전략 테이블 구성 장점 단점
Single Table members 한 장에 전부 조회 쉬움 NULL 폭탄, 컬럼 혼란
Class Table 부모/자식 테이블 분리 정규화, 역할 분리 JOIN 증가, 복잡
Concrete Table 자식마다 완전 별도 테이블 단순 전체 조회 어려움(UNION)

예시 1) Single Table 전략

CREATE TABLE members (
    member_id     INT PRIMARY KEY,
    name          VARCHAR(100),
    member_type   VARCHAR(20),  -- 'REGULAR' or 'VIP'
    point         INT,          -- Regular 전용 (VIP는 NULL)
    discount_rate FLOAT         -- VIP 전용 (Regular는 NULL)
);

문제는 NULL이 많아지고, “이 회원은 어떤 컬럼을 봐야 하는지”가 헷갈린다는 점이다.

예시 2) Class Table 전략

CREATE TABLE members (
    member_id INT PRIMARY KEY,
    name      VARCHAR(100)
);

CREATE TABLE regular_members (
    member_id INT PRIMARY KEY REFERENCES members(member_id),
    point     INT
);

CREATE TABLE vip_members (
    member_id INT PRIMARY KEY REFERENCES members(member_id),
    discount_rate FLOAT
);

정규화는 좋아지지만 조회 시 JOIN이 늘어난다.

추가 핵심 개념: “상속”은 객체지향의 코드 재사용 방식인데, DB는 데이터 저장/정합성 중심이라 상속이 필요 없도록 설계됐다. 그래서 매핑은 늘 ‘타협’이 된다.


3-4. 연관관계(Association) 문제 🔗

객체는 참조(Reference)로 연결되고, 참조에는 방향성이 있다.
DB는 외래키(FK) 하나로 양방향 JOIN이 가능하다. 철학이 다르다.

객체지향: 방향성이 있는 참조

class Team:
    def __init__(self, team_id: int, name: str):
        self.team_id = team_id
        self.name = name

class Member:
    def __init__(self, member_id: int, name: str, team: Team):
        self.member_id = member_id
        self.name = name
        self.team = team   # Member -> Team 단방향 참조

여기서 Team에서 members로 역방향 탐색을 하려면? 직접 관리해야 한다.

class Team:
    def __init__(self, team_id: int, name: str):
        self.team_id = team_id
        self.name = name
        self.members = []     # Team -> Member 참조 추가

class Member:
    def __init__(self, member_id: int, name: str, team: Team):
        self.member_id = member_id
        self.name = name
        self.team = team

dev_team = Team(1, "개발팀")
jay = Member(1, "kjjedd", dev_team)

dev_team.members.append(jeff)  # 양방향 동기화는 개발자 책임

양방향의 진짜 함정 😵

양방향은 “편해 보이지만” 동기화 비용이 숨겨져 있다.
한쪽만 수정하면 다른 쪽이 깨진다. 즉, 일관성 유지 책임이 코드로 넘어온다.


관계형 DB: FK 하나로 양방향 JOIN

CREATE TABLE teams (
    team_id INT PRIMARY KEY,
    name    VARCHAR(100)
);

CREATE TABLE members (
    member_id INT PRIMARY KEY,
    name      VARCHAR(100),
    team_id   INT REFERENCES teams(team_id)
);

-- Member -> Team
SELECT m.name, t.name
FROM members m
JOIN teams t ON m.team_id = t.team_id;

-- Team -> Members
SELECT t.name, m.name
FROM teams t
JOIN members m ON t.team_id = m.team_id;
객체: 참조 방향이 코드에 박힌다
DB  : FK만 있으면 JOIN으로 양쪽 다 된다

3-5. 탐색(Navigation) 문제 🧭

객체지향은 객체 그래프를 마음껏 탐색한다.<
DB는 탐색할 때마다 “쿼리”를 설계해야 한다. 여기서 쿼리 수가 폭발할 씨앗이 생긴다.

객체지향: 점(.)으로 자연스럽게 탐색

order = get_order(100)

print(order.customer.name)
print(order.customer.address.city)
print(order.items[0].product.category.name)

코드만 보면 “그냥 접근”인데, 실제로 DB가 연결되어 있다면?
이 탐색이 쿼리 호출로 바뀔 수 있다.


관계형 DB: 필요한 데이터마다 쿼리(또는 JOIN) 설계

-- 주문만
SELECT * FROM orders WHERE order_id = 100;

-- 고객도 필요하면 JOIN 추가
SELECT o.*, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_id = 100;

-- 주소까지 필요하면 JOIN 추가
SELECT o.*, c.name, a.city
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
JOIN addresses a ON c.address_id = a.address_id
WHERE o.order_id = 100;

N+1 문제의 씨앗 🌱

members = get_members()          # 쿼리 1번

for m in members:
    print(m.team.name)          # 멤버마다 팀을 조회 -> 멤버 수만큼 쿼리 추가
멤버가 100명이면?
- members 조회: 1번
- team 조회: 100번
총 101번 쿼리 = 1 + N

추가 핵심 개념: “탐색이 편한 코드”일수록 DB에서는 “조회 계획(쿼리)”이 숨어있을 수 있다. 객체지향의 탐색 방식이 그대로 DB 효율로 이어지지 않는다.


4. 패러다임 충돌 요약 표 📌

영역 객체지향 관계형 DB 충돌 포인트
정체성 메모리의 객체 PK로 식별되는 row 같은 row가 여러 객체로 생길 수 있음
구조 포함/중첩(Composition) 정규화 + JOIN 객체 구조 ↔ 테이블 구조 변환 필요
상속 언어 차원 지원 개념 없음 매핑 전략 선택이 강제됨
연관 참조(방향성) FK + JOIN(양방향 가능) 양방향 동기화 책임이 개발자에게 옴
탐색 객체 그래프 탐색 쿼리 설계 쿼리 폭증(N+1) 가능

5. 그래서 개발자는 어떻게 해왔나? 🛠️

결국 애플리케이션은 DB에서 row를 가져와서 객체로 바꿔야 한다.
반대로 객체를 저장하려면 row로 쪼개 SQL로 넣어야 한다.
이걸 직접 하면 “수동 매핑 지옥”이 열린다.

수동 매핑 예시: 조회 (Row → Object)

def get_user_by_id(cursor, user_id: int):
    cursor.execute(
        "SELECT user_id, name, email FROM users WHERE user_id = %s",
        (user_id,)
    )
    row = cursor.fetchone()

    if not row:
        return None

    user = User(
        user_id=row[0],
        name=row[1],
        email=row[2]
    )
    return user

수동 매핑 예시: 저장 (Object → SQL)

def save_user(cursor, user: "User"):
    cursor.execute(
        "INSERT INTO users (user_id, name, email) VALUES (%s, %s, %s)",
        (user.user_id, user.name, user.email)
    )
    

수동 매핑이 커지면 터지는 지점 💥

  • 테이블 100개면 매핑 코드도 100세트다
  • 컬럼 하나 추가되면 SQL + 매핑 코드가 같이 수정된다
  • 연관관계가 생기면 JOIN + 매핑 로직이 기하급수로 증가한다
  • 비즈니스 로직보다 DB 변환 코드가 더 많아지는 순간이 온다

추가 핵심 개념: 여기서 자연스럽게 “변환을 자동화하는 계층”이 필요해진다. 그게 ORM이 등장한 이유다.


6. 정리 🎯

객체지향과 관계형 DB는 태생이 다르다.

  • 객체지향: 현실 모델링, 행동 캡슐화, 탐색 중심
  • 관계형 DB: 정합성, 저장/조회 효율, 조인과 쿼리 중심

이 둘을 연결하려면 “매핑(변환)”이 필수다.
그리고 그 매핑을 사람이 직접 하면 유지보수 비용이 폭발한다.

한 줄 결론 ✨

객체는 탐색이 편하고,
DB는 쿼리가 강하다.
둘을 연결하려면 매핑이 필요하고,
매핑을 자동화하려고 ORM이 나온다.

체크리스트 ✅

  • 구조 문제: 포함(객체) vs 정규화+JOIN(DB)
  • 상속 문제: DB는 상속이 없어 전략 선택이 강제됨
  • 연관관계 문제: 객체는 방향성, DB는 FK로 양방향 조회
  • 탐색 문제: 객체 그래프 탐색이 DB에서는 쿼리 폭증(N+1)으로 이어질 수 있음
  • 수동 매핑은 확장할수록 비용이 폭발하므로 자동화 계층(ORM)이 필요해짐
profile
Gongbuhaja

0개의 댓글