w
> [!abstract] Introduction
> 애플리케이션 코드는 객체를 다루고, 데이터베이스는 테이블을 다룹니다. 이 둘 사이의 구조적 간극 — 임피던스 불일치*impedance mismatch* — 을 메우는 기술이 객체 관계 매핑*Object-Relational Mapping*, ORM입니다. ORM은 개발자가 SQL을 직접 작성하지 않고도 객체를 통해 데이터베이스를 조작할 수 있게 해주지만, N+1 문제나 아키텍처 패턴 선택 등 그 추상화 아래에는 이해해야 할 트레이드오프가 숨어 있습니다. 이 글에서는 ORM이 필요한 이유, 핵심 메커니즘, Active Record와 Data Mapper 패턴의 차이, 그리고 N+1 문제를 살펴봅니다.
## ORM이 필요한 이유 — 임피던스 불일치
[[Object-Oriented Programming]]에서 데이터는 객체 그래프*object graph* 형태로 존재한다. `Order` 객체가 `Item` 객체의 리스트를 참조하고, 각 `Item`은 `Product`를 참조하는 식이다. 반면 관계형 데이터베이스[^rdbms]에서 데이터는 행*row*과 열*column*로 이루어진 테이블 집합이며, 테이블 간의 관계는 외래 키*foreign key*로 표현된다.
이 두 모델은 근본적으로 다른 수학적 기반 위에 서 있다. 객체 모델은 그래프 이론에 가깝고, 관계형 모델은 집합론*set theory*에 기반한다. 이 구조적 비호환성을 객체-관계 임피던스 불일치*Object-Relational Impedance Mismatch*라 부른다 (Ambler, 2006). 임피던스 불일치는 구체적으로 다섯 가지 차원에서 발생한다.
### 세밀도의 차이
객체 모델에서는 `User` 클래스 안에 `Address`라는 별도의 값 객체*value object*를 둘 수 있다. 하지만 데이터베이스에서는 성능을 위해 `USERS` 테이블 하나에 주소 컬럼들을 평탄화*flattening*하여 저장하는 경우가 많다. 반대로 [[Normalization]] 규칙에 따라 하나의 객체가 여러 테이블로 분산될 수도 있다. ORM은 이런 세밀도*granularity*의 차이를 런타임에 자동으로 매핑해야 한다.
### 상속의 부재
객체 지향 언어에서 `PaymentMethod`라는 추상 클래스를 `CreditCard`와 `BankTransfer`가 상속받는 것은 자연스럽다. 그러나 SQL 표준에는 상속 개념이 없다. 테이블과 외래 키만 존재할 뿐이다. 그래서 ORM은 상속 구조를 테이블로 변환하기 위해 단일 테이블 전략*Single Table*, 조인 전략*Joined*, 구현 클래스별 테이블 전략*Table per Class* 같은 매핑 전략을 사용한다[^inheritance-mapping].
### 동일성의 정의 차이
Java나 Python에서 객체의 동일성*identity*은 메모리 주소(`==`)로, 동등성*equality*은 값 비교(`.equals()`)로 판단한다. 같은 데이터베이스 행을 표현하는 두 개의 서로 다른 객체 인스턴스가 메모리에 존재할 수 있다는 뜻이다. 반면 데이터베이스에서 레코드의 동일성은 오직 기본 키*primary key* 하나로 결정된다. ORM은 이 차이를 아이덴티티 맵*Identity Map*이라는 내부 캐시로 해결한다 — 같은 기본 키를 가진 엔티티가 메모리에 단 하나의 인스턴스로만 존재하도록 보장하는 것이다.
### 연관관계의 방향성
객체 참조는 본질적으로 단방향이다. `Parent` 객체가 `Child` 리스트를 참조하더라도, `Child`가 자동으로 `Parent`를 알지는 못한다. 그러나 데이터베이스의 외래 키는 방향이 없다. `JOIN` 연산으로 어느 쪽에서든 데이터를 조회할 수 있다. ORM은 이 비방향성 관계를 객체의 방향성 있는 참조로 변환하며, 양방향 관계*bidirectional relationship*를 구현할 때는 양쪽 객체의 참조를 일치시키는 동기화 로직을 수행한다.
### 데이터 탐색 방식의 차이
[[Object-Oriented Programming]]에서 데이터 접근은 그래프 순회*graph traversal* 방식이다. `order.getItems().get(0).getProduct().getName()`처럼 참조를 따라가며 데이터를 획득한다. 반면 데이터베이스는 `JOIN`으로 필요한 데이터를 한 번에 집합으로 가져오는 것이 효율적이다. 개발자가 객체 방식의 그래프 순회를 데이터베이스에 그대로 적용하면, 뒤에서 다룰 N+1 문제가 발생한다.
## ORM의 핵심 메커니즘
### 영속성 컨텍스트
ORM 프레임워크는 엔티티를 관리하기 위한 논리적 환경인 영속성 컨텍스트*Persistence Context*를 운영한다. Hibernate의 `Session`이나 JPA의 `EntityManager`가 대표적이다. 엔티티는 이 컨텍스트 안에서 네 가지 상태를 거친다.
![[entityLifecycle.svg]]
비영속*Transient*은 객체가 생성되었지만 아직 영속성 컨텍스트에 등록되지 않은 상태이고, 영속*Managed*은 컨텍스트가 관리하여 데이터베이스와의 동기화가 보장되는 상태이다. 준영속*Detached*은 한때 영속 상태였으나 세션이 닫히거나 명시적으로 분리되어 더 이상 추적되지 않는 상태이며, 삭제*Removed*는 삭제가 예약된 상태이다.
### 변경 감지
ORM의 강력한 기능 중 하나는 개발자가 명시적으로 `update()`를 호출하지 않아도 변경된 데이터를 자동으로 감지하여 반영하는 것이다. 이를 변경 감지*Dirty Checking*라 부른다.
기본 전략은 스냅샷 비교*Snapshot Comparison*이다. 엔티티가 영속성 컨텍스트에 로드될 때 초기 상태를 복사해 스냅샷으로 저장하고, 트랜잭션 커밋(플러시*flush*) 시점에 현재 상태와 필드 단위로 비교한다 (Mihalcea, 2014). 관리되는 엔티티가 많으면 원본과 스냅샷 모두를 메모리에 유지해야 하므로 오버헤드가 발생한다.
이를 최적화하는 방법이 바이트코드 위빙*Bytecode Enhancement*이다. 빌드 또는 런타임에 엔티티 클래스의 바이트코드를 조작하여, `setter` 호출 시점에 객체 스스로가 "변경됨" 플래그를 세우도록 만든다. 플러시 시점에 전체 스캔 대신 플래그가 설정된 객체만 처리하면 되므로 대규모 데이터 처리 시 성능이 크게 개선된다.
### 작업 단위
작업 단위*Unit of Work* 패턴은 트랜잭션 동안 발생하는 모든 엔티티 변경 사항을 추적하고, 커밋 시점에 일괄 처리한다. 객체를 수정할 때마다 `UPDATE` SQL을 즉시 보내는 것이 아니라, 변경 사항을 모았다가 한꺼번에 실행하여 네트워크 왕복 횟수를 줄이고 참조 무결성[^referential-integrity]을 고려한 SQL 실행 순서를 보장한다.
## Active Record vs Data Mapper
ORM의 아키텍처 패턴은 크게 두 가지로 나뉜다. 이 선택은 애플리케이션의 복잡도와 유지보수성에 직접적인 영향을 미친다.
### Active Record 패턴
액티브 레코드*Active Record*에서는 도메인 객체가 데이터와 영속성 로직을 모두 가진다. `user.save()`, `User.findAll()`처럼 객체 스스로가 데이터베이스 조작 능력을 갖는다.
```python
# Active Record 스타일 (Django ORM)
class User(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()
# 사용
user = User(name="Alex", email="
[email protected]")
user.save() # 객체 스스로 저장
users = User.objects.filter(name="Alex")
```
직관적이고 보일러플레이트*boilerplate* 코드가 적어 초기 개발 속도가 빠르다. CRUD[^crud] 중심의 단순한 애플리케이션이나 프로토타이핑에 적합하다. 그러나 단일 책임 원칙*Single Responsibility Principle, SRP*을 위반한다는 단점이 있다. 도메인 객체가 비즈니스 로직과 영속성 로직을 동시에 책임지므로 데이터베이스 결합도가 높아지고, 데이터베이스 없이 단위 테스트를 수행하기 어렵다. 대표적인 구현체로 Ruby on Rails의 ActiveRecord, Django ORM, Laravel Eloquent가 있다.
### Data Mapper 패턴
데이터 매퍼*Data Mapper*에서는 도메인 객체와 데이터베이스 사이에 별도의 매퍼(리포지토리*Repository* 또는 `EntityManager`)를 둔다. 도메인 객체는 데이터베이스의 존재를 알지 못하는 순수한 객체[^pojo]로 유지된다.
```java
// Data Mapper 스타일 (JPA/Hibernate)
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
private String email;
// getter, setter — 영속성 로직 없음
}
// 사용
User user = new User("Alex", "
[email protected]");
entityManager.persist(user); // 외부 매퍼가 저장
List<User> users = userRepository.findByName("Alex");
```
도메인 모델과 영속성 계층이 완전히 분리*decoupling*되므로 유지보수성이 뛰어나고, 데이터베이스 없이도 독립적인 단위 테스트가 가능하다. 복잡한 비즈니스 로직을 가진 엔터프라이즈 애플리케이션에 적합하다. 반면 초기 설정과 학습 곡선이 높고, 리포지토리와 매핑 설정 등 작성해야 할 코드량이 많아 단순한 애플리케이션에서는 과도한 엔지니어링*over-engineering*이 될 수 있다. 대표적인 구현체로 Hibernate, SQLAlchemy, MikroORM이 있다.
### 무엇을 선택할 것인가
| 기준 | Active Record | Data Mapper |
| :--- | :--- | :--- |
| 영속성 책임 | 도메인 객체 스스로 담당 | 외부 Repository/Mapper가 담당 |
| 결합도 | DB 스키마와 강하게 결합 | DB와 완전히 분리 |
| 테스트 용이성 | 어려움 (DB 의존성) | 용이함 (순수 객체 테스트) |
| 적합한 상황 | 단순 CRUD, 프로토타입, 소규모 앱 | 복잡한 도메인, 대규모 엔터프라이즈 |
핵심은 애플리케이션의 복잡도에 있다. 도메인 로직이 단순하다면 Active Record의 생산성이 빛을 발하고, 도메인 로직이 복잡해질수록 Data Mapper의 관심사 분리가 필수적이 된다.
## N+1 문제
ORM 사용 시 가장 빈번하게 마주치는 성능 함정이 N+1 문제*N+1 Problem*이다.
### 발생 원리
`User` 100명을 조회한 뒤, 루프를 돌며 각 사용자의 `Post` 목록에 접근하는 상황을 생각해 보자.
```python
users = User.objects.all() # 쿼리 1회: SELECT * FROM users
for user in users:
print(user.posts.all()) # 쿼리 N회: SELECT * FROM posts WHERE user_id = ?
```
첫 번째 쿼리로 100명의 사용자를 가져온 뒤, 각 사용자의 게시글을 가져오기 위해 100번의 추가 쿼리가 발생한다. 총 101번의 쿼리가 실행되는 것이다. 이는 ORM이 기본적으로 연관 데이터를 실제 접근 시점까지 로딩을 미루는 지연 로딩*Lazy Loading* 전략을 사용하기 때문이다.
### 해결 방법
가장 직접적인 해결책은 페치 조인*Fetch Join*이다. 처음 조회할 때 연관 데이터를 `JOIN`으로 함께 가져오는 것이다.
```python
# Django: select_related (FK/OneToOne), prefetch_related (ManyToMany/역참조)
users = User.objects.prefetch_related('posts').all() # 쿼리 2회로 해결
```
```sql
-- JPA/JPQL에서의 Fetch Join
SELECT u FROM User u JOIN FETCH u.posts
```
또 다른 방법은 배치 페칭*Batch Fetching*이다. 지연 로딩이 발생할 때 한 건씩이 아니라 설정된 개수만큼 `IN` 절로 묶어서 가져오는 것이다. Hibernate의 `@BatchSize(size=100)`을 설정하면 N+1이 아닌 $\lceil N / 100 \rceil + 1$번의 쿼리로 완화된다.
중요한 것은 ORM이 생성하는 SQL을 항상 의식하는 것이다. 대부분의 ORM은 실행되는 SQL을 로깅하는 기능을 제공한다. 개발 단계에서 이 로그를 켜 두면 N+1 문제를 조기에 발견할 수 있다.
## 정리
ORM은 객체와 관계형 데이터베이스 사이의 임피던스 불일치를 해소하여 개발 생산성을 높여주는 도구다. 영속성 컨텍스트, 변경 감지, 작업 단위 같은 내부 메커니즘이 자동으로 동작하기 때문에 개발자는 비즈니스 로직에 집중할 수 있다. Active Record와 Data Mapper라는 두 가지 아키텍처 패턴은 애플리케이션의 복잡도에 따라 선택해야 하며, N+1 문제처럼 추상화 아래에 숨겨진 성능 함정을 인지하고 제어하는 것이 성공적인 ORM 활용의 열쇠다.
ORM을 블랙박스로 사용하는 것이 아니라 내부 동작 원리를 이해하고, 필요에 따라 추상화 수준을 조절할 수 있어야 한다. 때로는 ORM이 생성한 SQL이 충분히 효율적이고, 때로는 직접 작성한 SQL이 더 나은 선택이다. 이 판단을 내릴 수 있는 것이 ORM을 제대로 아는 것이다.
---
## 출처
- Ambler, S.W. (2006). *The Object-Relational Impedance Mismatch*. Available at: http://www.agiledata.org/essays/impedanceMismatch.html.
- Fowler, M. (2002). *Patterns of Enterprise Application Architecture*. Addison-Wesley. pp. 160–165 (Active Record), pp. 165–170 (Data Mapper).
- Mihalcea, V. (2014). 'The anatomy of Hibernate dirty checking mechanism', *vladmihalcea.com*. Available at: https://vladmihalcea.com/the-anatomy-of-hibernate-dirty-checking/.
- King, G. and Bauer, C. (2024). 'What is object/relational mapping?', *Hibernate ORM*. Available at: https://hibernate.org/orm/what-is-an-orm/.
- Baeldung (2024). 'N+1 Problem in Hibernate and Spring Data JPA'. Available at: https://www.baeldung.com/spring-hibernate-n1-problem.
[^rdbms]: Relational Database Management System. 관계형 모델에 기반하여 데이터를 저장·관리하는 시스템. MySQL, PostgreSQL, Oracle 등이 대표적이다.
[^inheritance-mapping]: 상속 매핑 전략의 상세한 트레이드오프 분석은 별도의 포스트에서 다룬다. 단일 테이블 전략은 조회 성능이 가장 좋지만 `NOT NULL` 제약을 걸 수 없고, 조인 전략은 정규화를 준수하지만 `JOIN` 비용이 발생한다.
[^referential-integrity]: 참조 무결성. 외래 키가 참조하는 레코드가 반드시 존재해야 한다는 데이터베이스 무결성 제약. 예를 들어 부모 레코드가 삭제되기 전에 자식 레코드가 먼저 삭제되어야 한다.
[^crud]: Create, Read, Update, Delete. 데이터의 기본적인 네 가지 조작 연산.
[^pojo]: Plain Old Java Object. 특정 프레임워크에 종속되지 않는 순수한 자바 객체. Python에서는 관례적으로 Plain Old Python Object라 부르기도 한다.