Skip to content

ORM의 역사 - 객체와 관계형 데이터베이스 사이에서

누구나 처음에는 그럴싸한 생각을 한다.

Makonea
·2026년 7월 6일·34분

들어가며...

내 코드에는 이런 주석이 있다.

Code
// TODO: 나중에 고칠 것

작성일은 3년 전이다. 나에게 “나중”은 꽤 넓은 개념이다.

대부분의 소프트웨어는 이렇게 시작한다.
처음에는 임시방편으로 시작한다.
SQL을 직접 쓰고, row를 객체로 옮기고, 객체를 다시 INSERT나 UPDATE로 바꾼다. 처음에는 별것 아닌 반복 작업처럼 보인다.

하지만 같은 코드를 계속 쓰다 보면 생각이 바뀐다.

“이거 매번 직접 해야 하나?”

ORM(Object-Relational Mapping)은 여기서 시작했다. SQL을 없애기 위해 태어난 마법이 아니라, 객체지향 애플리케이션이 관계형 데이터베이스에 상태를 저장할 때 반복해서 생기는 번역 비용을 줄이려는 시도였다.

앱 개발자는 객체를 다루고 싶다.
DB쪽 개발자와 DB는 행(row), 열(column), 키(key), 조인(join), 트랜잭션(transaction)을 다루고 싶어 한다.

둘 다 나름 맞는 말을 한다.
그래서 이 사람들은 맨날 싸운다. 그리고 언제나 그렇듯, 이 위대한 패러다임의 충돌을 수습하는 건 'TODO: 나중에 고칠 것'이라는 주석을 남긴 3년 전의 나 자신이다. 나는 아직도 고치지 않았고, 경험상 앞으로도 고치지 않을 것이다. 그리고 이것을 업계에서는 '안정된 아키텍처'라고 부른다.

대규모 데이터 뱅크의 미래 사용자들은 데이터가 기계 내부에서 어떻게 조직되어 있는지, 즉 내부 표현 방식이 어떻게 되어 있는지를 알 필요로부터 보호되어야 한다. 그러한 정보를 제공하는 프롬프트 서비스는 만족스러운 해결책이 아니다. 사용자들이 터미널과 대부분의 응용 프로그램에서 수행하는 작업은 데이터의 내부 표현이 변경되거나, 심지어 외부 표현의 일부 측면이 변경되더라도 영향을 받지 않아야 한다. 데이터 표현의 변경은 질의, 갱신, 보고 트래픽의 변화와 저장 정보 유형의 자연스러운 증가 때문에 필요해지는 경우가 많다.

현재 존재하는 비추론적이고 형식화된 데이터 시스템들은 사용자에게 트리 구조 파일이나 약간 더 일반적인 네트워크 모델을 제공한다. 제1절에서는 이러한 모델들의 부적절함을 논의한다. n항 관계에 기반한 모델, 데이터베이스 관계의 정규형, 그리고 보편적 데이터 서브언어라는 개념을 소개한다. 제2절에서는 논리적 추론 이외의 관계 연산 몇 가지를 논의하고, 중복성과 일관성 문제에 적용한다.
-E. F. Codd, “A Relational Model of Data for Large Shared Data Banks,” Communications of the ACM, Vol. 13, No. 6, pp. 377–387, 1970. DOI: 10.1145/362384.362685.

1. 관계형 데이터 베이스가 있으라!(Let there be RDBMS!)

1970년, E. F. Codd는 "A Relational Model of Data for Large Shared Data Banks"에서 관계형 모델을 제안했다.1

핵심을 요약하자면 이렇다.

  • 데이터를 테이블처럼 표현한다.

  • 행과 열로 구조화한다.

  • 관계와 제약으로 무결성을 유지한다.

  • 물리적 저장 방식과 논리적 조회 방식을 분리한다.

IBM의 설명처럼, Codd가 원한 것은 사용자가 데이터베이스의 물리적 내부 구조를 몰라도 정보를 다룰 수 있게 하는 것이었다.2
여기까지만 보면 사무직의 영혼의 짝, '마이크로소프트 엑셀'과 다를 바가 없어 보인다. 실제로 데이터를 2차원 표로 정리하고, 필터링하고, 누군가 다시 열어볼 수 있게 남겨두려는 욕망이라는 점에서는 둘의 직관이 완전히 같다.

하지만 관계형 데이터베이스는 단순히 "공유 폴더에 굴러다니는 표"가 아니다.
엑셀이 인간의 눈을 위한 느슨한 표라면, 관계형 데이터베이스는 그 표에 키(Key), 제약 조건, 조인, 트랜잭션, 동시성 제어라는 엄격한 규칙을 강제한 '시스템'이다.

이 발상은 강력했다. 관계형 데이터베이스는 곧바로 업무 시스템과 기업 데이터의 절대적인 표준이 되었다.

이유는 단순하다. 데이터는 결코 하나의 프로그램만을 위해 존재하지 않기 때문이다. 오늘 저장한 데이터는 내일 회계 시스템이 읽어가고, 관리자 화면에 뿌려지며, 밤마다 배치 작업이 쓸어가고, 나중에는 BI 도구가 통계를 낸다.

특정 애플리케이션은 유행을 타지만, 데이터베이스는 모든 시스템의 교집합으로서 훨씬 길게 살아남는다. 우리는 프론트엔드와 백엔드를 3년마다 갈아엎지만, DB는 함부로 건드리지 못한다.

회사에서 생태계의 먹이사슬은 보통 이렇다. 코드보다 오래 사는 것은 데이터고, 데이터보다 오래 사는 것은 '최종_진짜최종.xlsx'라는 엑셀 파일명이다.
이건 농담으로 썼지만, 슬프게도 농담이 아니다.

A:당신은 왜 여기에 갇혔나요?
B:저는 엑셀을 데이터 베이스로 썼어요
A:나한테서 떨어져

2. DB의 갈비뼈를 취하여 짝을 지으려 하였으나, 그 모양이 맞지 아니하더라

1980년대와 1990년대를 지나며 객체지향 프로그래밍이 대중화됐다.

객체 세계에서는 User, Order, Product 같은 개념이 자연스럽다. 객체는 상태와 행동을 함께 가진다. 다른 객체를 참조하고, 메서드로 상태를 바꾸며, 캡슐화와 상속을 통해 모델을 확장한다.

C#
var order = orderRepository.Find(orderId);
var city = order.Customer.Address.City;
order.Cancel();

객체지향 코드에서 위 흐름은 자연스럽다.
주문은 고객을 알고, 고객은 주소를 알고, 주소는 도시를 가진다. 개발자는 객체 그래프를 따라가며 생각한다. 일반적으로 우리가 쉽게 생각하는 구조이다.

그런데 관계형 데이터베이스는 그렇게 생각하지 않는다.

DB에는 객체 참조가 없다.
외래키가 있다.
DB에는 객체 그래프가 없다.
조인이 있다.
DB에는 객체 생명주기가 없다.
트랜잭션, row version, constraint가 있다.
DB에는 메서드 호출이 없다.
집합 연산과 질의 계획이 있다.

일반적으로 객체 지향으로 개발하는 개발자는 “하나의 주문”에서 출발해 관련 객체를 따라가고 싶어 한다.(실제로 디버깅이 쉽다)

C#
order.Customer.Address.City

DB는 “필요한 행들의 집합”을 한 번에 계산하고 싶어 한다.

SQL
SELECT ...
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN addresses a ON a.customer_id = c.address_id
WHERE o.id = :orderId;

둘 다 틀린 것은 아니다. 세계를 나누는 축이 다르다.

객체지향은 보통 정체성(identity), 참조(reference), 행위(behavior), 캡슐화(encapsulation) 를 중심으로 모델을 세운다.
반면 관계형 모델은 관계(relation), 튜플(tuple), 키(key), 제약(constraint), 집합 연산(set operation) 을 중심으로 모델을 세운다.

이 차이가 객체-관계형 임피던스 불일치(object-relational impedance mismatch)다.

객체지향 세계

관계형 DB 세계

객체 identity

primary key

객체 참조

foreign key

객체 그래프 탐색

JOIN

메서드 호출

UPDATE / INSERT / DELETE

캡슐화된 상태 변경

transaction 안의 row 변경

상속

table-per-hierarchy, joined table, table-per-class

컬렉션

related table 또는 join table

객체 생명주기

persistence state, row version, constraint

메모리상의 현재 상태

DB에 commit된 durable state

특히 상속은 관계형 DB와 잘 맞지 않는다.
객체지향에서는 AdminUser : User, PremiumCustomer : Customer 같은 모델이 자연스럽고 우아하기까지하다.

관계형 DB에는 상속이라는 구조가 기본적으로 없다.
그래서 ORM은 table-per-hierarchy, joined table, table-per-class 같은 매핑 전략을 선택해야 한다.

객체의 “자연스러움”이 DB의 “자연스러움”으로 자동 변환되지 않는 것이다.

그래서 ORM은 이 차이를 없애는 도구가 아니라, 두 세계 사이의 번역 비용을 줄이는 도구로 처음 등장한 것이다.

3. 초기 ORM: "객체를 그냥 저장하면 안 되나?"

1990년대에는 객체를 DB에 저장하려는 여러 시도가 있었다.

한쪽에는 객체지향 데이터베이스(OODBMS)가 있었다. 객체를 테이블로 찢지 말고, 객체 그대로 저장하자는 생각이다.

그런데 OODBMS도 약점이 있었다. ODMG 표준은 힘이 약했고, 임기응변 질의(ad-hoc query)와 리포팅이 관계형만큼 편하지 않았으며, 벤더 종속도 컸다. 게다가 기업 데이터의 중심은 이미 관계형 DB 쪽으로 기울어 있었다. SQL 생태계, 리포팅 도구, 운영 경험, DBA 문화, 트랜잭션 처리, 표준화, 벤더 제품군이 모두 관계형 DB를 중심으로 커졌다.

그래서 현실적인 질문은 이렇게 바뀌었다.

객체를 그대로 저장하는 DB를 새로 쓰자.

가 아니라,

이미 있는 관계형 DB에 객체를 어떻게 우겨 넣을 것인가?

초기 상용 ORM의 원형으로 자주 언급되는 것이 TopLink다. TopLink는 원래 Smalltalk 쪽에서 출발했고, 이후 Java 세계로 넘어가며 객체와 관계형 데이터 사이의 매핑을 제공했다.3

중요한 점은 여기다.

ORM은 처음부터 추상적인 아름다움 때문에 나온 게 아니다. 이미 기업은 관계형 DB를 쓰고 있었고, 애플리케이션 코드는 점점 객체지향으로 짜이고 있었다. 둘 사이의 반복 매핑이 너무 귀찮았다.
그래서 있는 DB에 우겨 넣기로 했다

4. 상속을 테이블에 우겨 넣는 세 가지 방법

90년대 00년대 초의 객체지향의 꽃은 상속이었다.
하지만 데이터베이스는 상속이라는 고상한 개념을 이해할 지능이 없다. 이 우아한 상속 트리를 2차원 표에 구겨 넣기 위해 ORM은 세 가지 기괴한 매핑 전략을 제시해야 했다.

C#
public class User
{
    public long Id { get; set; }
    public string Email { get; set; } = "";
}

public class AdminUser : User
{
    public string AdminRole { get; set; } = "";
}

public class PremiumCustomer : User
{
    public int LoyaltyPoint { get; set; }
}

코드에서는 AdminUserUser이고, PremiumCustomerUser다. 하지만 관계형 DB에는 이런 상속 구조가 기본 개념으로 존재하지 않는다. 그래서 ORM은 객체의 상속 계층을 테이블 구조로 번역해야 한다.

대표적인 전략은 세 가지다.

4.1 단일 테이블 전략

Table-per-Hierarchy, Single Table Inheritance

상속 트리 전체를 하나의 테이블에 넣는 방식이다. 부모와 자식 클래스의 모든 컬럼을 한 테이블에 몰아넣고, DTYPE 또는 Discriminator 컬럼으로 실제 타입을 구분한다.

Text
Users
- Id
- Email
- DTYPE
- AdminRole
- LoyaltyPoint

해석하면, 10평짜리 원룸에 일반 유저, 관리자, VIP 고객을 전부 밀어 넣고, 가슴에 이름표를 붙여 구분하는 방식이다.

장점은 단순하다. 조회가 빠르다. 모든 데이터가 한 테이블에 있으므로 상속 계층을 조회할 때 조인이 필요 없다.

문제는 테이블이 점점 이상해진다는 점이다. AdminUser에게만 필요한 AdminRole 컬럼은 일반 유저와 프리미엄 고객에게는 의미가 없다. PremiumCustomer에게만 필요한 LoyaltyPoint도 관리자에게는 의미가 없다. 결국 많은 컬럼이 NULL로 채워진다.

상속 계층이 작고, 타입별 컬럼 차이가 크지 않다면 실용적이다. 무식하지만 빠르다. 문제는 상속 계층이 커질수록 테이블이 점점 스위스 치즈처럼 변한다는 것이다.

조회는 단순하고 빠르지만, nullable column이 늘어나며 스키마가 지저분해진다.

4.2 조인 테이블 전략

Joined Table, Table-per-Subclass

부모 클래스와 자식 클래스를 각각 별도 테이블로 만들고, 자식 테이블이 부모 테이블의 기본키를 외래키로 참조하는 방식이다.

Text
Users
- Id
- Email

AdminUsers
- Id (FK -> Users.Id)
- AdminRole

PremiumCustomers
- Id (FK -> Users.Id)
- LoyaltyPoint

해석하면, 데이터베이스가 가장 좋아할 만한 방식이다. 공통 속성은 부모 테이블에 두고, 타입별 속성은 각 자식 테이블에 둔다. 정규화 관점에서는 깔끔하다.

하지만 객체 하나를 조립하려면 테이블을 다시 합쳐야 한다. AdminUser 하나를 온전히 읽으려면 UsersAdminUsers를 조인해야 한다.

SQL
SELECT *
FROM users u
JOIN admin_users a ON a.id = u.id
WHERE u.id = :id;

상속 계층이 깊어질수록 조인이 늘어난다. 타입별 조회가 많거나, 여러 자식 타입을 한 번에 다루는 polymorphic query가 많으면 비용이 커진다.

단, 조인이 늘어난다고 곧바로 deadlock이 생긴다는 뜻은 아니다. 일반적인 읽기 조인은 보통 deadlock의 직접 원인이 아니다. 진짜 문제는 조인 비용, 실행 계획 복잡도, 인덱스 설계, ORM이 생성하는 쿼리의 예측 가능성이다.

스키마는 정규화되어 깔끔하지만, 조회 때 조인이 늘어나고 실행 계획이 복잡해진다.

4.3 구현 클래스마다 테이블 전략

Table-per-Class, Table-per-Concrete-Class

부모 테이블을 두지 않고, 각 구체 클래스마다 독립된 테이블을 만든다. 부모 클래스의 공통 컬럼은 각 자식 테이블에 복사된다.

Text
AdminUsers
- Id
- Email
- AdminRole

PremiumCustomers
- Id
- Email
- LoyaltyPoint

해석하면, 코드에서는 상속을 썼지만 DB에는 “각자 알아서 살라”고 선언한 방식이다. 각 구체 클래스는 자기 테이블을 가진다.

문제는 공통 타입으로 조회하는 순간 시작된다.

C#
List<User> users = userRepository.FindAllUsers();

DB 입장에서는 Users라는 부모 테이블이 없다. 그러면 모든 자식 테이블을 뒤져야 한다.

SQL
SELECT Id, Email, 'AdminUser' AS DTYPE
FROM admin_users

UNION ALL

SELECT Id, Email, 'PremiumCustomer' AS DTYPE
FROM premium_customers;

자식 타입이 적을 때는 참을 만하다. 그러나 타입이 늘어날수록 공통 조회가 무거워진다. 공통 컬럼이 각 테이블에 복사되므로 스키마 변경도 번거롭다.

타입별 테이블은 단순하지만, 공통 조회와 polymorphic query가 어려워지고 공통 컬럼이 중복된다.

세 전략을 비교하면 이렇다.

전략

장점

단점

어울리는 경우

Single Table / Table-per-Hierarchy

조회가 단순하고 빠름

nullable column 증가, 테이블 비대화

타입 차이가 작고 상속 계층이 단순할 때

Joined Table / Table-per-Subclass

정규화 관점에서 깔끔함

조회 시 조인 증가, 실행 계획 복잡화

데이터 정합성과 스키마 정리가 중요할 때

Table-per-Class / Concrete Table

타입별 테이블이 독립적임

UNION 필요, 공통 컬럼 중복, 다형 조회 어려움

부모 타입 조회가 거의 없고 타입별 독립성이 강할 때

결국 ORM의 상속 매핑은 “정답 고르기”가 아니라 “어느 비용을 감당할 것인가”에 가깝다.

객체지향은 상속을 통해 개념을 자연스럽게 표현하려 한다. 관계형 DB는 데이터를 중복 없이, 제약을 지키며, 집합으로 조회하려 한다. 둘의 자연스러움은 다르다. ORM은 그 차이를 없애지 못한다. 단지 어느 쪽의 불편을 더 크게 감수할지 선택하게 해줄 뿐이다.

4.4 그래서 요즘은 조합(Composition)을 선호한다

상속 매핑 전략을 보고 있으면 자연스럽게 이런 생각이 든다.

“그냥 상속을 안 쓰면 되는 것 아닌가?”
그래서 현대 객체지향 설계에서는 상속보다 조합(composition)을 선호하는 경우가 많다.
AdminUser : User처럼 타입 계층을 깊게 만드는 대신, UserRole, Permission, Profile, Subscription 같은 객체를 가지도록 모델링한다.

C#
public class User
{
    public long Id { get; set; }
    public string Email { get; set; } = "";
    public UserProfile Profile { get; set; } = new();
    public List<Role> Roles { get; set; } = new();
    public Subscription? Subscription { get; set; }
}

상속은 “A는 B다”라고 말한다.

Text
AdminUser is a User.
PremiumCustomer is a User.

조합은 “A는 B를 가진다”라고 말한다.

Text
User has Roles.
User has Profile.
User has Subscription.

관계형 DB 입장에서는 조합이 상속보다 자연스럽다. DB는 원래 테이블과 테이블을 키로 연결하는 데 익숙하다.

Text
users
- id
- email

user_profiles
- user_id
- display_name
- avatar_url

roles
- id
- name

user_roles
- user_id
- role_id

subscriptions
- user_id
- plan
- expires_at

이 구조는 상속 매핑보다 DB입장에서는 덜 기괴하다. DTYPE으로 타입을 구분하지 않아도 되고, 자식 클래스마다 테이블을 찢은 뒤 UNION으로 다시 합칠 필요도 없다. 대부분의 경우 foreign key, join table, unique constraint로 표현할 수 있다.

하지만 조합도 공짜는 아니다.

객체 입장에서는 user.Profile처럼 자연스러운 소유 관계로 보이지만, DB 입장에서는 별도 테이블과 외래키다. user.Roles는 객체 컬렉션이지만, DB에서는 user_roles 같은 join table이다. user.Subscription은 nullable reference처럼 보이지만, DB에서는 row가 있을 수도 없을 수도 있는 관계다.

또 조합을 쓰면 새로운 질문들이 나온다.

  • User를 삭제하면 UserProfile도 같이 삭제해야 하는가?

  • RoleUser가 소유하는가, 아니면 여러 사용자가 공유하는가?

  • Subscription은 값 객체인가, 독립 엔티티인가?

  • cascade delete를 켤 것인가?

  • orphan row를 어떻게 처리할 것인가?

  • aggregate boundary는 어디까지인가?

  • 한 transaction 안에서 어디까지 일관성을 보장할 것인가?

조합(Composition)의 세계로 넘어오면, 개발자는 두 가지 극단적인 정신 질환 중 하나를 시스템에 구현해야 한다.

첫 번째는 '저장강박증(고아 row 방치)'다. CASCADE를 두려워한 나머지 아무것도 지우지 않고 버티는 거다. 탈퇴한 지 5년 된 유저의 썩어가는 프로필 사진, 이미 만료된 빈 구독권, 주인 없는 장바구니가 DB 구석에 산처럼 쌓여 조용히 늙어간다. 그러다 어느 날 평화롭게 LEFT JOIN을 하던 중 이 데이터를 보고나서 개인정보 보호법을 어겼구나라며 경악한다.

두 번째는 '병적 방화증(CASCADE DELETE)'이다. 유저가 방을 빼겠다고 탈퇴 버튼을 누르는 순간, 시스템은 아주 성실하게 그의 방뿐만 아니라 카펫, 복도, 계단, 그리고 운 나쁘게 엮여있던 다른 장바구니 데이터까지 화염방사기로 시원하게 소독해 버리는 것이다. 그러니까 남겨놔야할 데이터도 지울 수 있다는 지점이 있다.

재미있는 점은, 과거에는 이 방화 버튼을 누르는 것을 '아키텍처의 대참사'라고 불렀지만, 요즘은 '개인정보 보호법(Privacy Law)' 덕분에 아주 훌륭한 컴플라이언스 준수로 칭송받는다. 국가에서 정신병 중 하나에게 합법성을 준 아주 좋은 사례이다.

즉, 조합(Composition)은 상속 매핑의 고통을 줄여주지만, 객체 세계와 관계형 세계의 본질적인 마찰을 없애지는 못한다. 문제는 “상속을 어떻게 우겨 넣을 것인가”에서 “불을 지를 것인가, 쓰레기를 안고 살 것인가”로 이동했을 뿐이다. 대체로 쓰레기를 영원히 안고 사는 것보다. 가끔 불을 지르고 사는게 더 낫다는 것만 기억하면 된다.

그래도 실무적으로는 조합이 대체로 더 낫다. 관계형 DB는 상속보다 관계를 더 잘 표현한다. 객체지향에서도 깊은 상속 계층보다 작은 객체들의 조합이 변경에 강한 경우가 많다.

그래서 요즘의 결론은 대체로 이쪽이다.

상속은 정말 타입 계층이 도메인에서 안정적으로 존재할 때만 쓰고, 대부분은 조합과 관계로 모델링한다.

ORM의 역사는 이 교훈을 꽤 비싼 방식으로 알려주었다.

여기서 '비싼 방식'이라는 말은, 수많은 선배 개발자들이 터져나가는 DB 락(Lock)과 N+1 쿼리 로그를 모니터로 지켜보며 조용히 짐을 싸서 퇴사했다는 뜻의 업계 은어이다.
그들의 희생 덕분에 우리는 오늘도 has_many를 침착하게 타이핑한다.

5. Java 엔터프라이즈와 EJB Entity Bean의 시대

EJB Entity Bean은 EJB 1.0(1998)부터 있었고, EJB 2.0(2001)을 지나며 Java 엔터프라이즈의 표준적인 영속성 모델로 굳어졌다.

문제는 무거웠다는 것이다.

설정이 많고, 컨테이너 의존성이 크고, 개발자가 단순한 테이블 하나를 저장하려고 해도 너무 많은 의식 절차를 치러야 했다.

엔터프라이즈라는 단어는 가끔 이상한 면죄부처럼 쓰인다. 복잡하면 "엔터프라이즈급"이라고 부르고, 느리면 "견고하다"고 부르고, 쓰기 괴로우면 "표준"이라고 부른다. EJB Entity Bean은 그 시절의 공기를 잘 보여준다.

이 반작용으로 Hibernate(대표적 자바 ORM)가 커졌다.

Hibernate는 Gavin King이 만든 Java ORM이다.
핵심은 "평범한 Java 객체(POJO)를 DB에 매핑하자"는 쪽이었다.

개발자는 컨테이너에 묶인 무거운 객체가 아니라, 보통의 클래스를 만들고 싶었다. Hibernate는 lazy loading, dirty checking, unit of work, identity map 같은 기능으로 객체 상태 변화를 DB 트랜잭션에 묶어주었다.

이때부터 ORM은 단순한 row mapper가 아니라 애플리케이션의 영속성 계층 전체를 관리하는 도구가 된다.

6. Fowler가 언어를 정리하다

2002년 Martin Fowler의 Patterns of Enterprise Application Architecture는 ORM 주변의 패턴 언어를 정리했다.4

여기서 나온 이름들이 지금도 ORM 문서를 읽을 때 계속 보인다.

  • Active Record

  • Data Mapper

  • Unit of Work

  • Identity Map

  • Lazy Load

  • Repository

이 이름들이 중요한 이유는, ORM 논쟁이 단순히 "SQL을 쓰느냐 마느냐" 문제가 아니기 때문이다.

예를 들어 Active Record는 객체 하나가 자기 저장도 책임지는 방식이다.

Ruby
user = User.find(1)
user.name = "Kim"
user.save

반대로 Data Mapper는 도메인 객체와 DB 매핑 책임을 분리하려 한다.

Text
User 객체는 도메인 규칙을 가진다.
UserMapper 또는 ORM infrastructure가 DB 저장을 담당한다.

Active Record는 빠르고 단순하다. Data Mapper는 복잡하지만 도메인 모델을 DB 구조에서 조금 더 떼어놓을 수 있다.

이 선택은 취향보다 보통 애플리케이션의 체급에 더 좌우된다.

작은 CRUD 앱에서는 Active Record가 편하다.
복잡한 도메인과 긴 생명주기를 가진 시스템에서는 Data Mapper 계열이 더 낫다. 물론 복잡한 시스템에서 Data Mapper를 쓴다고 자동으로 고급 설계가 되는 것은 아니고, 삽질도 추상화하면 더 비싸진다.

7. Rails Active Record: ORM의 대중화

2004년 Ruby on Rails가 등장하며 Active Record 스타일은 대중적인 웹 개발 문법이 됐다.

Rails 공식 문서는 Active Record를 MVC의 M, 즉 데이터와 비즈니스 로직을 표현하는 계층으로 설명한다.5

Rails식 ORM의 매력은 명확했다.

Ruby
class User < ApplicationRecord
  has_many :orders
end

테이블 이름, 컬럼 이름, 관계 이름을 규칙에 맞추면 많은 설정이 사라진다. 이것이 convention over configuration이다.

초기 웹 서비스, 관리자 페이지, CRUD 중심 앱에서는 생산성이 엄청났다.

문제는 생산성이 높은 도구일수록 세계관이 되기 쉽다는 점이다.

처음에는 “SQL을 덜 써도 된다”였는데, 어느 순간 “SQL을 몰라도 된다”로 오해된다. 대부분의 프로그래밍 법칙이 그렇다. 처음에는 특정한 문제를 해결하기 위해 나온 규칙이었는데, 시간이 지나면 맥락이 빠지고 마법의 주문처럼 외워진다. 흔히 마법사의 제자 딜레마라고 부른다.

ORM도 마찬가지다.

ORM이 만든 SQL을 읽지 못하면, ORM은 편의 도구가 아니라 블랙박스가 된다. 블랙박스는 작을 때는 선물 상자처럼 보이고, 장애가 나면 관처럼 보인다.

물론 요즘의 ORM은 예전보다 훨씬 성숙해졌다. 규칙도 많이 정리됐다. N+1을 조심하라, lazy loading을 함부로 믿지 마라, 필요한 관계는 명시적으로 가져와라, 복잡한 조회는 SQL이나 query builder로 빼라, ORM이 만든 SQL을 로그로 확인하라. 이런 교훈은 이제 책들과 프레임워크 문서에 그냥 상식에 가깝다.

그래서 잘 정리된 팀과 작은 CRUD 시스템 안에서 ORM은 정말 선물 상자처럼 보인다. 테이블을 만들고, 모델을 정의하고, 관계를 선언하면 꽤 많은 일이 자연스럽게 돌아간다.

물론, 선물 상자처럼 보인다는 사실이 위험을 없애지는 않지만 어쨌건 잘 돌아간다. 단지 위험이 관례, 설정, 로깅, 리뷰 규칙, 프레임워크 기본값 뒤로 숨어 있고, 언제 목줄을 물어뜯을지도 모르지만, 일단 돌아간다.

그러다 어느 날 목록 조회 하나가 고객 수만큼 쿼리를 날리고, 개발자는 그제야 선물 상자 안에 작은 쿼리 공장이 들어 있었다는 사실을 알게 된다.

그리고 프로그래머로서 한층 성장했구나 라며 안도하지만, 보통 ORM의 추상화 누수가 시스템을 무너뜨리는 속도보다, 납품받은 업체가 자본금 고갈로 망하는 속도가 더 빠르기 때문에 실무적으로 크게 걱정할 일은 없다.

8. 표준화: JPA와 Entity Framework

Hibernate 같은 오픈소스 ORM이 널리 쓰이면서 Java 세계는 결국 표준화를 향해 갔다.

Java EE 5의 큰 방향은 "개발을 쉽게 만들자"였고, JPA(Java Persistence API)는 그 흐름 속에서 등장했다.6

JPA의 의미는 Hibernate 하나를 쓰자는 것이 아니라, Java에서 ORM을 다루는 표준 API를 만들자는 것이었다.

다만 이 표준화는 표준이 시장을 이끈 아름다운 사건이라기보다, 표준이 현실에 밀려난 사건에 가깝다.

EJB Entity Bean은 Java EE가 공식으로 내세운 영속성 모델이었지만, 개발자들은 이미 더 가벼운 POJO 기반 Hibernate 쪽으로 이동하고 있었다. 결국 JPA는 그 흐름을 흡수했다. 다만 JPA를 Hibernate 하나의 표준화로 보는 것은 정확하지 않다. JPA 전문가 그룹(JSR 220)은 Hibernate뿐 아니라 TopLink와 JDO의 설계도 함께 참고했다. Hibernate가 결정적이었던 것은 맞지만, 표준의 모양은 여러 야생 구현의 경험이 합쳐져 만들어졌다.

이런 일이 가끔 있다. 위원회가 산을 설계하는 동안, 사람들은 이미 옆길로 다니고 있었고, 나중에 위원회가 그 길에 표지판을 세운다. 표지판에는 "공식 등산로"라고 적힌다.

비슷하게 .NET 세계에서는 Entity Framework가 등장했다. Microsoft 문서에 따르면 초기 Entity Framework는 .NET 3.5 SP1과 Visual Studio 2008 SP1에 포함되어 2008년 8월 출시됐고, Code First가 나오기 전이라, EDMX/디자이너 기반의 Database First 중심 O/RM을 제공했다.7

이 시점부터 ORM은 프레임워크의 주변 도구가 아니라, 플랫폼 기본 기능에 가까워졌다.

Java에는 JPA/Hibernate가 있고, .NET에는 Entity Framework가 있고, Python에는 Django ORM과 SQLAlchemy가 있고, Ruby에는 Rails Active Record가 있다.

ORM은 싫어해도 피하기 어려운 기술이 됐다.

9. ORM은 왜 계속 욕먹는가

ORM은 생산성을 올렸지만, 동시에 아주 많은 착각을 만들었다.
위에서 언급한 마법사의 제자 딜레마이다.

첫 번째 착각은 SQL이 사라진다는 믿음이다.

SQL은 사라지지 않는다. 숨어 있을 뿐이다.

ORM으로 작성한 코드도 결국 SQL로 번역된다. 문제는 번역된 SQL이 언제, 몇 번, 어떤 조인으로, 어떤 인덱스를 타고 실행되는지 개발자가 모르면 성능 문제가 늦게 발견된다는 점이다.

두 번째 착각은 객체 그래프를 탐색하는 방식이 DB 접근에도 자연스럽다는 믿음이다.

C#
foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name);
}

객체 코드로는 평범하다. 하지만 lazy loading이 걸려 있으면 N+1 쿼리가 된다.

세 번째 착각은 ORM이 관계형 모델을 대체한다는 믿음이다.

DB는 단순 저장소가 아니다. 제약 조건, 트랜잭션, 인덱스, 조인, 집계, 격리 수준, 실행 계획을 가진 별도 시스템이다.

ORM을 제대로 쓰려면 DB를 몰라도 되는 것이 아니라, DB를 알아야 한다. 이 문장이 초보자에게는 사기처럼 들릴 수 있다. 도구가 숨겨준다면서 왜 알아야 하냐고. 하지만 원래 추상화는 완전히 숨기는 게 아니라, 평소에는 숨기고 문제가 생기면 내려갈 수 있게 해주는 것이다.

Ted Neward가 ORM을 "The Vietnam of Computer Science"라고 부른 이유도 이 지점이다.8
이 표현 자체는 2004년경 컨퍼런스 발언에서 처음 나왔고, 이를 정리한 글이 2006년에 나왔다. 처음에는 쉽게 들어가는데, 깊이 들어갈수록 빠져나오기 어려운 수렁이 된다. 이것이 ORM을 '컴퓨터 과학의 베트남'전이라고 하는 것이다.

Fowler도 ORM 혐오에 대해 비슷한 균형을 잡는다. ORM 문제는 실제로 어렵지만, 그렇다고 모두가 직접 ORM을 새로 짜던 1990년대식 고통으로 돌아가는 것도 답은 아니라는 쪽이다.9

10. Micro-ORM과 SQL-first의 반작용

ORM이 너무 많은 것을 하려 하자 반대 방향의 도구들이 힘을 얻었다.

Micro-ORM은 보통 이런 태도를 가진다.

Text
SQL은 직접 쓰자.
대신 결과를 객체에 매핑하는 반복만 줄이자.

Dapper 같은 도구가 이 계열인데, 이 방식은 ORM의 세계관을 거부한다. Unit of Work, dirty checking, lazy loading 같은 기능을 크게 기대하지 않는다. 개발자가 SQL을 명시적으로 쓰고, 도구는 row를 객체로 옮겨 담는 일을 도와준다.

Python의 SQLAlchemy도 흥미로운 위치에 있다. SQLAlchemy는 ORM이지만, 동시에 SQL 표현 언어(Core)를 강하게 제공한다. SQLAlchemy 릴리스 기록을 보면 2006년부터 이어져 왔고, 1.4에서 Core/ORM 통합 쿼리 체계가 도입되었으며, 2.0에서 그 방향이 주된 사용 방식으로 정리됐다.10

최근의 query builder, type-safe SQL, Prisma/Drizzle 같은 도구들도 넓게 보면 비슷한 고민 위에 있다.

개발자는 여전히 반복 매핑을 하면서 실력 없는 개발자 소리 듣는 것을 싫어하지만,
ORM이 SQL을 완전히 가리는 것도 싫어한다.

그래서 역사는 한쪽으로 끝나지 않았다.

Text
Raw SQL:
  명시적이지만 반복이 많다.

Full ORM:
  편하지만 숨겨진 비용이 있다.

Micro-ORM / query builder:
  SQL을 인정하면서 반복을 줄이려 한다.

11. NoSQL이라는 우회로

ORM 문제를 해결하는 더 과격한 방법도 있었다.

객체를 관계형 테이블로 매핑하지 말고, 객체와 비슷한 문서(document)를 그대로 저장하자는 흐름이다.

MongoDB 같은 document database는 JSON-like document 모델을 앞세웠다.11애플리케이션이 이미 JSON이나 객체 형태로 데이터를 다룬다면, 굳이 row와 join으로 찢지 말고 비슷한 모양으로 저장하자는 발상이다.

이건 ORM 문제에 대한 일종의 우회였다.

Text
기존 ORM:
  객체 -> 테이블 -> 객체

Document DB:
  객체 비슷한 문서 -> 문서 -> 객체 비슷한 문서

물론 우회로가 공짜는 아니었다. 조인, 정규화, 트랜잭션, 중복 데이터, 일관성 관리 문제가 다른 형태로 돌아왔다.
근데 어차피 이런 류의 문제가 늘 그렇듯, 테이블 지옥을 피하려다가 문서 지옥에 들어가는 경우도 있었다.지옥의 이름만 바뀐것뿐이다.

여기에서 흥미로운 점은 관계형 DB가 이 흐름을 그냥 보고만 있지 않았다는 것이다.

PostgreSQL은 JSON/JSONB 같은 타입을 강화했고, PostgreSQL 9.4의 JSONB 소개는 관계형 저장소와 비관계형 저장소 사이의 선택을 줄여주는 기능으로 설명됐다.12 즉 RDBMS는 document DB의 장점을 일부 흡수했다.

그래서 오늘의 결론은 "관계형이 죽고 NoSQL이 이겼다"가 아니다.

오히려 이렇게 됐다.

Text
RDBMS:
  트랜잭션, 조인, 제약, SQL, JSON까지 일부 흡수

Document DB:
  유연한 문서 모델, 빠른 개발, 계층형 데이터 저장

현실:
  둘 다 살아남고, 서로의 장점을 조금씩 훔쳐간다

프로그래밍 역사는 도둑질의 역사와 같다.
좋은 아이디어는 결국 옆집 담장을 넘는다.

12. 임피던스 불일치의 다른 사례

ORM은 객체와 관계형 DB 사이의 불일치를 일부 줄였다.
하지만 서로 다른 모델을 붙일 때 생기는 마찰 자체가 사라진 것은 아니다. 오늘날에는 비슷한 문제가 백엔드 도메인 모델과 프론트엔드 화면 상태 사이에서도 반복된다.

백엔드는 보통 도메인 객체, 엔티티, DTO, API response를 만든다.

프론트엔드는 화면 상태를 원한다.

이 둘은 다르다.

예를 들어 백엔드에는 Order, Customer, Payment, Shipment가 각각 자연스러운 경계일 수 있다. 하지만 화면은 "주문 상세 페이지에 필요한 모든 것"을 원한다. 이름, 주소, 결제 상태, 배송 상태, 버튼 활성화 여부, 권한, 에러 메시지, 로딩 상태까지 한 번에 필요하다.

DB와 객체 사이에서 생기던 문제가 이제 API와 UI 상태 사이에서 다시 나온다.

Text
과거:
  객체 모델 != 관계형 테이블

현재:
  백엔드 도메인 모델 != 프론트엔드 화면 상태

GraphQL은 이 문제에 대한 한 답이다. REST endpoint가 너무 많이 주거나(over-fetching), 너무 적게 줘서 여러 번 호출하게 되는 문제를 클라이언트가 필요한 필드만 질의하는 방식으로 줄이려 한다.13

tRPC는 TypeScript 풀스택 환경에서 서버 API와 클라이언트 호출 사이의 타입 불일치를 줄이려 한다. 공식 설명도 end-to-end typesafe API를 전면에 내세운다.14

늘 역사는 반복되고, 인간의 어리석음도 반복된다.

ORM은 DB와 객체 사이의 번역기였다.

GraphQL, tRPC, BFF, API composition 계층은 백엔드와 화면 상태 사이의 번역기다.

차이는 위치일뿐이다.
예전에는 DB 경계에서 고통이 컸고, 지금은 네트워크/API/UI 상태 경계에서 고통이 커졌다. 추상화는 발전했지만, 서로 다른 모델을 붙일 때 생기는 마찰은 어디론가 간다. 사라지지는 않는다. 그냥 이사한다.

13. ORM 사용 규율은 팀에서 어떻게 강제할 수 있나

개인 개발자가 "ORM이 만든 SQL을 읽자"고 마음먹는 것은 쉽다.
팀이 몇 달 뒤에도 그렇게 하게 만드는 것은 어렵다.

그래서 ORM은 흔히 '아예 안쓰거나','모든 것에서 쓰거나' 둘 중 하나로 나뉘는데, 그렇다해도 사람들이 ORM을 적당히 통제하면서 쓸 수 있는 방식을 모아둔 것은 다음과 같다.

  • 개발/스테이징에서 SQL 로그를 켜고, 느린 쿼리와 쿼리 개수를 PR에서 확인한다.

  • API 요청 하나당 쿼리 개수 상한을 둔다. 예를 들어 목록 조회 하나가 200개 SQL을 날리면 테스트가 실패해야 한다.

  • N+1 감지 도구를 붙인다. Rails의 Bullet, Hibernate statistics, EF Core logging/interceptor 같은 식이다.

  • 기본 lazy loading을 꺼두고, 필요한 곳에서만 명시적으로 켠다.

  • Include, JOIN FETCH, projection DTO 같은 조회 전략을 코드 리뷰 체크리스트에 넣는다.

  • 복잡한 리포트와 집계는 ORM entity graph가 아니라 SQL/view/query builder 쪽으로 보내는 규칙을 둔다.

  • 운영에서 slow query log, execution plan, DB metric을 애플리케이션 trace와 묶어 본다.

  • Repository나 Query Facade를 둘 때는 "ORM wrapper"가 아니라 쿼리 의도를 드러내는 이름을 쓴다.

좋은 규칙은 개발자를 시험에 들게 하지않는다.
사람은 바쁘면 lazy loading을 까먹고, 금요일 오후에는 실행 계획을 안 본다.
그러니까 명시적인 규칙으로 잡아두는게 편하다.

14. 결론

All non-trivial abstractions, to some degree, are leaky

사소하지 않은 모든 추상화는 어딘가에서 반드시 샌다.
-Joel Spolsky, Trello 창시자

ORM의 역사는 "객체가 이겼다"도 아니고 "관계형 DB가 이겼다"도 아니다.

둘 다 살아남았다.

객체지향 애플리케이션은 여전히 도메인 모델, 서비스, 상태 전이를 객체로 표현한다.

관계형 DB는 여전히 트랜잭션, 제약, 조인, 집계, 장기 데이터 보존에서 강하다.

ORM은 그 사이의 번역 비용을 줄이는 도구다.

문제는 ORM을 도구가 아니라 세계관으로 받아들일 때 생긴다.

ORM을 잘 쓰는 태도는 대략 이렇다.

  • 단순 CRUD와 상태 변경은 ORM을 쓴다.

  • 복잡한 조회, 리포트, 집계는 SQL이나 query builder를 쓴다.

  • ORM이 만든 SQL을 반드시 볼 줄 안다.

  • lazy loading은 기본 편의가 아니라 위험한 기능으로 본다.

  • DB 제약 조건과 트랜잭션을 애플리케이션 코드보다 낮은 레벨의 안전장치로 둔다.

  • 도메인 모델과 DB 스키마가 완전히 같아야 한다고 믿지 않는다.

ORM은 실패한 추상화가 아니다.

다만 새는 추상화다.

그리고 새는 추상화는 버리라는 뜻이 아니다. 어디서 새는지 알고 쓰라는 뜻이다.
애초에 모든 추상화는 반드시 새기 마련이다.

짧은 연표

시기

흐름

1970

Codd가 관계형 모델 제안

1980~1990년대

객체지향 언어와 기업 RDBMS가 각각 확산

1990년대

TopLink 같은 초기 O/R 매핑 도구 등장

2000년대 초

EJB Entity Bean의 무거움에 대한 반작용으로 Hibernate 확산

2002

Fowler의 PoEAA가 ORM 주변 패턴 언어 정리

2004

Rails Active Record가 convention 기반 ORM을 대중화

2006

JPA 1.0, SQLAlchemy 초기 릴리스

2008

Entity Framework 1.0 출시

2010년대

NoSQL/document DB, micro-ORM, SQL-first, query builder 흐름 확산

2014

PostgreSQL 9.4 JSONB 등 RDBMS가 문서형 저장의 장점을 일부 흡수

2015~

GraphQL 등 API/UI 상태 경계의 데이터 불일치를 줄이려는 흐름 확산

2020년대

type-safe query, schema-driven ORM, SQL 회귀, end-to-end type-safe API 흐름 공존

각주

  1. E. F. Codd, "A Relational Model of Data for Large Shared Data Banks", Communications of the ACM, 1970.
  2. IBM, "The relational database".
  3. Oracle, "Introduction to TopLink".
  4. Martin Fowler, "Catalog of Patterns of Enterprise Application Architecture".
  5. Ruby on Rails Guides, "Active Record Basics".
  6. Oracle, "The Java Persistence API - A Simpler Programming Model for Entity Persistence".
  7. Microsoft Learn, "Past Releases of Entity Framework".
  8. Ted Neward, "The Vietnam of Computer Science", 2006.
  9. Martin Fowler, "Orm Hate", 2012.
  10. SQLAlchemy, "Download / Release history" and "SQLAlchemy 2.0.0 Released".
  11. MongoDB, "Document Database - NoSQL".
  12. PostgreSQL Wiki, "What's new in PostgreSQL 9.4".
  13. GraphQL, "GraphQL Queries".
  14. tRPC, "End-to-end typesafe APIs made easy".