ORM 대신 SQL과 맞춤형 인터페이스를 통해 관계형 데이터베이스와 직접 상호작용하는 방식을 살펴봅니다.
저는 늘 ORM의 설계에 관심이 있었습니다. ORM이 어떻게 작동하는지, 어떤 서로 다른 접근법이 있는지, 어떻게 활용할 수 있는지 알고 싶었습니다. 여러 가지를 사용해 봤고, 심지어 하나를 만들기도 했습니다. 하지만 지난 몇 년간 저는 점차 데이터베이스와 더 직접적으로 작업하는 쪽으로 기울어 왔습니다. 평범한 SQL로 쿼리를 실행하고, 데이터베이스에서 오는 원시 데이터를 처리하거나, 관계형 데이터베이스 작업을 위한 맞춤형 저수준 추상화를 만드는 식입니다. 코드가 데이터베이스와 대화하는 방식에서의 이러한 변화가 제가 SQLite 데이터베이스 작업용 Ruby 젬인 Extralite를 만든 이유입니다.
왜 ORM 대신 데이터베이스와 직접 상호작용하는 방식을 선호할까요? SQL에 능숙한 개발자라면 ORM의 한계가 자명할 것입니다. 윈도 함수, 공통 테이블 표현식(CTE), 심지어 RETURNING 절 같은 기능을 지원하지 않습니다. ORM 계층 자체도 상당한 의존성입니다(ActiveRecord 코드베이스는 약 ~43KLoC입니다). 메모리와 CPU 시간 측면에서 성능 비용을 부과합니다. 그리고 그 핵심 기능인 행을 객체에 매핑하는 일은 “컴퓨터 과학의 베트남”이라고 묘사된 안티패턴입니다.
물론 ActiveRecord가 Ruby/Rails 커뮤니티에서 데이터베이스와 대화하는 가장 인기 있는 방법이며, Ruby와 다른 언어용의 수많은 ORM에 큰 영향을 미쳤다는 사실을 잘 알고 있습니다. 하지만 제게 ActiveRecord는 언제나 어딘가 잘못된 것처럼 느껴졌습니다. 웹 앱과 플랫폼을 만드는 일을 오래 할수록 그 점은 더 분명해집니다. ActiveRecord는 관계형 데이터에 잘못된 추상화를 제공합니다. ActiveRecord는 단일 레코드, 즉 각 레코드가 그 자체로 하나의 엔터티라는 생각에 집중합니다. 그러나 실제로 정규화된 데이터베이스에는 태그나 레이블처럼 엔터티로 다룰 필요가 없는 부수 데이터가 담긴 테이블이 많습니다. 시계열 데이터와 이벤트 로그도 있습니다. 오늘날 관계형 데이터베이스는 키-값 저장소나 작업 큐로도 사용됩니다. 이런 유형의 데이터에서는 개별 레코드보다 레코드 _집합_에 관심을 두는 경우가 많으며, 실제로 엔터티 추상화가 필요하지 않습니다. 그러한 용도에서 ActiveRecord는 투박하고 낭비적으로 보입니다.
ActiveRecord의 API는 ActiveRecord::Base를 상속하고 수백 개의 인스턴스 및 클래스 메서드를 지닌, 각 행마다 별도의 객체를 둔다는 발상을 중심으로 돌아갑니다. 따라서 프로그래머는 자신이 레코드 집합의 항목이 아니라 실제로 분리된 객체를 다루고 있다고 생각하게 될 수 있습니다. ActiveRecord를 쓰는 초보 프로그래머는 예를 들어 #each를 이용해 여러 레코드를 삭제하거나 갱신하고 싶어 할 수 있습니다.
def make_bracelet(material)
beads = Bead.where(material:).all
bracelet = Bracelet.new(beads)
beads.each(&:delete) # <== a separate DELETE query for each bead
bracelet
end
위 예제에서는 관심 있는 구슬을 얻기 위해 SELECT 쿼리 하나를 실행한 다음, 각 게시물마다 DELETE 쿼리를 실행하므로 N+1 상황이 됩니다. 물론 읽어 온 구슬을 순회하는 대신 delete_all을 호출하는 간단한 해결책이 있습니다.
def make_bracelet(material)
beads = Bead.where(material:).all
bracelet = Bracelet.new(beads)
Bead.where(material:).delete_all
bracelet
end
이제 쿼리는 두 개, 즉 동일한 WHERE 절을 가진 SELECT 쿼리와 DELETE 쿼리로 줄었습니다.
select * from beads where material = ?;
delete from beads where material = ?;
여기에는 한 가지 문제가 있습니다. 두 쿼리를 트랜잭션 안에서 실행하지 않는 한, 이 두 쿼리가 동일한 레코드를 건드린다는 보장이 없습니다. 두 번째 문제는 여전히 데이터베이스를 두 번 왕복한다는 점입니다. 단일 쿼리에서 레코드를 읽고 삭제할 수는 없을까요? 예, 가능합니다. 적어도 PostgreSQL과 SQLite 같은 데이터베이스에서는 RETURNING 절을 사용하면 됩니다.
delete from beads where material = ? returning *;
이 쿼리를 실행하면 관련 레코드를 삭제하고 그 내용을 반환합니다. 제가 알기로 ActiveRecord에는 이런 종류의 쿼리를 위한 API가 없습니다. 하지만 필요한 것은 평범한 SQL 쿼리를 실행할 방법뿐이며, Extralite를 사용하면 쉽게 할 수 있습니다.
def make_bracelet(material)
beads = @db.query <<~SQL
delete from beads where material = ? returning *
SQL
Bracelet.new(beads)
end
여기서 우리가 이룬 것은 데이터베이스와의 상호작용을 관련 행을 삭제하면서 그 내용을 반환하는 단일 쿼리로 줄인 것입니다. 물론 이는 쿼리를 평범한 SQL로 표현한다는 뜻이고, 이것이 망설여질 수도 있습니다. 하지만 잠시만 따라와 주세요. 제가 말하려는 바가 있습니다.
ActiveRecord와 일반적인 ORM의 주요 판매 포인트 중 하나는 DSL의 편리함입니다. SQL을 작성할 필요 없이, 원하는 어떤 쿼리든 구성할 수 있는 우리의 마법 같은 DSL을 쓰기만 하면 됩니다. 실제로는 수백 개의 인스턴스 및 클래스 메서드를 갖춘 훌륭한 범용 API를 얻게 되며, 이 API를 통해 원하는 방식으로 쿼리를 동적으로 만들 수 있습니다. 하지만 정말 그 수백 개의 메서드가 모두 필요할까요?
제가 살펴본 거의 모든 웹 앱 코드베이스에서 관찰한 점은, 아주 드문 경우를 제외하면 특정 앱이 실행할 서로 다른 쿼리의 수는 유한하며 실제로 비교적 적다는 것입니다. 그 모든 CRUD 앱, 즉 _CRUD 원숭이가 작성한 앱 😉_은 기본적으로 몇 가지 서로 다른 유형의 쿼리만 실행합니다. 예를 들어 단순한 블로그 앱은 다음과 같은 서로 다른 쿼리를 수행할 수 있습니다.
Post.Create(title: 'foo', body: 'bar') # create
post = Post.find(42) # read
post.save # update
post.delete # delete
Post.order_by(:stamp).all # list
Post.where(category: 'baz').order_by(:stamp).all # list by category
앱에서 일부 연관 관계를 가져오고, 여러 방식으로 게시물을 필터링하고 정렬할 수 있어야 할 수도 있으므로 몇 가지 다른 쿼리를 실행해야 할 것입니다. 하지만 완전한 기능을 갖춘 시각적 쿼리 빌더 앱을 만드는 것이 아니라면, 그 수는 테이블당 서로 다른 쿼리 열두 개 정도일 것이라고 대략 추측합니다!
따라서 제가 생각하기에 던져야 할 질문은 이렇습니다. 서로 다른 쿼리를 수십 개만 실행하면 된다면, 애초에 왜 DSL을 써야 할까요? 정말 ActiveRecord의 모든 마법과 표현력이 필요할까요? 그 쿼리를 SQL로 직접 표현하면 안 될까요? 앱의 Ruby 코드 양과 비교하면 작성해야 하는 SQL의 양은 새 발의 피입니다!
물론 평범한 SQL 쿼리를 작성하는 것보다 ActiveRecord를 사용하는 가장 큰 장점은 상용구가 없고 무료로 얻는 모든 기능이며, 그중 아마도 연관 관계가 가장 중요하다고 주장할 수 있습니다. 하지만 제 생각에 그런 추상화는 비엔터티 데이터를 쿼리에 포함하거나 윈도 함수를 사용하는 것처럼 조금 더 고급이거나 특이한 일을 시도하는 순간 자체 무게를 이기지 못하고 무너집니다.
관계형 데이터베이스와 상호작용하면서 SQL의 기본적인 이해조차 없는 것은 좋은 생각이 아니라고 봅니다. 그렇습니다. 바이브 코딩이 우리 직업을 휩쓸었고, 많은 사람이 이제 코드는 중요하지 않으며 아예 들여다봐서도 안 된다고 생각하는 듯합니다. 이제 우리는 모두 _프롬프트 원숭이_이고, 하루 종일 Anthropic에 많은 돈을 지불하며 “할당량”을 살피고, 토큰을 절약할 창의적인 방법을 궁리하고(얼마나 어리석은 발상입니까!), 온갖 멋지고 이국적인 초복잡 루프와 하니스, “스킬”, AGENTS.md 파일과 그 모든 _헛소리_를 만드는 데 시간을 써야 한다는 것입니다. 그냥, 아시다시피, 작동하는 평범한 코드를 작성하는 대신에 말입니다.
이 글의 저자는 자신의 코드를 이해하는 일이 여전히 중요하고, 데이터베이스가 무엇을 하는지 이해하는 일도 여전히 중요하며, 성능도 여전히 중요하고, 컴퓨팅 자원을 사용할 때 최소한의 절약 정신을 지니는 것 역시 여전히 중요하다고 생각합니다(사실 현재의 환경 문제를 고려하면 훨씬 더 중요합니다!).
게다가 한편으로는 Ruby 런타임을 더 빠르게 만들기 위해 그토록 많은 노력이 투입되는데, Ruby on Rails를 사용하는 개발자들은 성능에 대해 너무나 경솔하고 거의 무지한 태도를 보인다는 점이 흥미롭습니다. “누가 신경 써요? 컴퓨팅은 무한한 자원이고, 에이전트가 알아서 할 거예요. 코드를 더 빠르게 만들라고 하면 되죠.” 실제로 누가 그들을 탓할 수 있겠습니까? AI 플랫폼 가격이 AI 컴퓨팅의 실제 비용을 반영하지 않는 한, 바이브 코딩을 하는 사람들이 자신들의 코드(본 적도 없는!) 성능에 전혀 신경 쓰지 않는 것을, 컴퓨팅 인프라에서 최대한을 뽑아내는 데 관심을 두지 않는 것을 누가 탓할 수 있겠습니까? 그런 의미에서 Ruby 자체를 더 빠르게 만드는 몇몇 아주 재능 있는 개발자의 경이로운 작업은, 실제 Ruby on Rails 앱에 매일 추가되는 터무니없이 많은 멈출 수 없는 슬롭을 고려하면 시지프스의 과업입니다. 하지만 이야기가 샜습니다.
MVC의 M은 테이블 행을 엔터티 객체에 매핑하고, 그러한 객체를 얻어 조작하기 위한 표현력 있는 API를 제공하는 것이 주된 책임인 일종의 ORM이라고 오랫동안 받아들여져 왔습니다. 하지만 범용 쿼리 빌더를 사용해 데이터베이스와 상호작용하는 대신, 데이터베이스와 상호작용하기 위한 우리만의 _맞춤형 API_를 만들 수 있지 않을까요? 단순한 블로그 앱의 예로 돌아가서, 게시물을 다루도록 맞춤 제작된 인터페이스가 있다고 상상해 봅시다.
posts = PostsStore.new(db)
id = posts.create(title: 'foo', body: 'bar') # create
post = posts.by_id(id) # id
posts.update_by_id(id, title: 'FOO') # update
posts.delete_by_id(id) # delete
posts.all # list
posts.all_by_category(category: 'baz') # list by category
앞서와 동일한 여섯 가지 쿼리이지만, 행을 반환하는 메서드는 맞춤 객체 대신 평범한 Ruby 해시를 사용합니다. 앱의 관점에서는 그저 인터페이스가 바뀐 것일 뿐입니다. 본질적으로 블로그 앱에서 게시물을 읽고 조작하기 위한 맞춤형 API를 만든 셈입니다. ActiveRecord와 마찬가지로 전체 데이터베이스 계층은 메서드 집합 뒤에 추상화됩니다. 컨트롤러/비즈니스 로직 코드는 깊고 체이닝 가능한 API를 가진 Post 클래스를 사용할 필요 없이, 단지 인터페이스의 메서드를 호출합니다.
하지만 가장 중요한 변화는 게시물을 다룰 때 새 쿼리를 마음대로 만들어 낼 수 없다는 점입니다. 존재하는 쿼리를 사용하거나 PostsStore 클래스에 새 쿼리를 추가해야 합니다. PostsStore의 구현은 매우 간단합니다.
class PostsStore
def initialize(db)
@db = db
end
def create(title:, body:)
@db.query_splat <<~SQL, title, body
insert into posts (title, body)
values (?, ?)
returning id
SQL
end
def by_id(id)
@db.query_single_row <<~SQL, id
select id, title, body
from posts
where id = ?
SQL
end
...
def all
@db.query <<~SQL
select id, title, body, stamp
from posts
order by stamp desc
SQL
end
end
여기서는 Extralite의 핵심 기능 중 하나, 즉 단일 값, 단일 행, 행 집합 등 원하는 어떤 형태로든 데이터를 추출할 수 있는 능력을 활용합니다. 아래에서 보이겠지만 Extralite에는 더 고급 기능도 있습니다.
우리가 하지 않는 모든 일을 보세요. 엔터티 객체가 없고, 원하는 데이터는 필요한 형태인 평범한 Ruby 해시로 데이터베이스에서 반환됩니다. 쿼리, 매개변수, 열 모두 명시적이고 쉽게 이해할 수 있습니다. 수행하는 할당 횟수도 크게 줄였습니다. 물론 이런 종류의 코드는 스캐폴딩할 수 있고(CRUD 용도라면), 그렇게 하고 싶다면 좋아하는 슬롭 에이전트로 생성할 수도 있습니다.
또한 불필요한 추상화를 모두 어떻게 제거했는지 보세요. 엔터티 클래스도 없고, 쿼리 구축을 주도하는 DSL도 없습니다. Extralite의 도움으로 데이터베이스와 직접 대화하고, 데이터베이스 계층을 추상화하는 편리한 API를 제공합니다.
이 설계는 처음에는 당혹스러워 보일 수 있습니다. 앱을 개발하면서 즉석에서 쿼리를 만드는 기능은 어디에 있을까요? 엔터티와의 상호작용은 어디에 있을까요? 비즈니스 로직은 어디에 두어야 할까요? 이 모든 질문의 답은 스토어 클래스입니다. 스토어 클래스는 특정 종류의 데이터와 관계된 모든 것을 캡슐화합니다. 스토어 클래스는 게시물과 상호작용하기 위한 그 인터페이스로 기능해야 합니다. 게시물로 무엇을 해야 하든 PostsStore 클래스에 있어야 합니다. 예를 들어 연관 관계와 함께 게시물을 읽어야 한다면, 올바른 쿼리를 실행하고 연관 관계를 포함한 데이터를 반환하는 메서드를 추가하면 됩니다. 여기서도 Extralite가 도움을 줄 수 있습니다. 조인된 행의 프로젝션을 변환하여 결과 집합을 사실상 객체 그래프로 변환할 수 있기 때문입니다.
class PostsStore
POSTS_WITH_AUTHORS = Extralite::Transform do
{
id: integer.identity, # posts.id
title: text, # posts.title
body: text, # posts.body
stamp: integer, # posts.stamp
author: {
id: integer.identity, # authors.id
name: text # authors.name
}]
}
end
def all_with_authors
@db.query POSTS_WITH_AUTHORS, <<~SQL
select posts.id, posts.title, posts.body, posts.stamp,
authors.id, authors.name
from postss
join authors on authors.id = posts.author_id
order by posts.stamp desc
SQL
end
end
이제 이렇게 말할 수도 있습니다. ActiveRecord라면 무료로 얻을 수 있었던 것을 위해 이 모든 코드를 쓴다고요! 그렇습니다. 이를 구현하려면 SQL과 보조 코드를 조금 작성해야 합니다. 그러나 앱의 관점에서 API는 이전만큼 단순하고, 스토어 인터페이스에서 얻는 데이터에는 필요한 모든 것이 담겨 있으며, 단지 평범한 Ruby 해시로 표현될 뿐입니다.
# here's how a view might look like, using Papercraft:
POSTS_VIEW = ->(posts:) {
div(id: 'posts') {
posts.each { |p|
div(class: 'item') {
h3 p[:title]
h4 p[:author][:name]
markdown snippet(p[:body])
}
}
}
# Here's how a controller might look like, Using Syntropy:
def call(req)
posts = @posts_store.all_with_authors
html = LAYOUT.render(posts:, &POSTS_VIEW)
req.render_html(html)
end
솔직히 이것이 ActiveRecord를 쓰는 것보다 뭐가 더 어렵습니까? 게다가 앱 코드베이스 전체에 흩어진 Post.where(...).order_by(...) 같은 깊이 있는 체인 메서드 호출 대신, 특정 종류의 엔터티(블로그 게시물)를 위해 맞춤 제작된 특수 목적 인터페이스를 만들었습니다. 이 인터페이스는 게시물로 하고 싶은 모든 일을 처리하고, 마법 없이 일반 메서드 호출 뒤에 이를 추상화합니다.
제가 짚고 싶은 또 다른 세부 사항은 게시물 스토어가 실제로는 싱글턴처럼 사용되는데도 클래스로 구현되어 있다는 점입니다. 클래스로 구현하는 이유는 스토어 객체에 데이터베이스 연결을 주입할 수 있기 때문입니다. 필요에 따라 원한다면 다른 여러 방식으로 할 수 있습니다. 예를 들어 연결 대신 연결 풀을 전달하거나, 인터페이스를 전역 싱글턴 모듈로 구현할 수도 있습니다. 결국 모두 같은 생각으로 귀결됩니다. 모델 계층은 엔터티 객체 클래스를 주도하는 DSL이 아니라 인터페이스라는 것입니다.
스토어 추상화(사실상 그저 인터페이스입니다)는 시계열 데이터, 보조 데이터, 키-값 저장소, 작업 큐 같은 비엔터티 데이터와 상호작용하는 데에도 사용할 수 있습니다. 스토어가 단일 테이블에 대응할 필요조차 없습니다. 구성 요소가 SQL 쿼리이므로 관련 데이터를 읽고 조작하기 위해 모든 사용 가능한 SQL 기능을 활용하여 원하는 수의 테이블에 접근할 수 있습니다.
이 설계에서 더 논의할 가치가 있는 한 측면은 인터페이스를 메서드 호출의 매개변수로 전달한다는 생각입니다. Ruby에서는 보통 인터페이스를 말하지 않고, 직접 상호작용하는 개별 객체를 말합니다. 인터페이스 역시 객체지만 데이터를 캡슐화하지는 않으며(어느 정도 상태를 가질 수는 있습니다), 기능을 캡슐화합니다. 실제로 그것은 메서드의 컨테이너입니다.
저는 이 “인터페이스 패턴”을 꽤 오랫동안 사용해 왔습니다. 예를 들어 UringMachine에서는 줄여서 UringMachine 또는 UM 인스턴스인 인터페이스를 통해 I/O를 수행합니다.
require 'uringmachine'
machine = UM.new
machine.write(UM::STDOUT_FILENO, "hello, world!")
machine.open('foo.txt', UM::O_RDONLY) do |fd|
buf = +''
size = machine.read(fd, buf, 8192)
machine.write(UM::STDOUT_FILENO, buf)
end
본질적으로 모든 I/O 작업은 이 인터페이스를 통해 이뤄집니다. 즉 I/O를 수행하는 모든 곳에서 인터페이스에 대한 참조가 필요합니다. 이 설계는 UringMachine만의 고유한 것이 아닙니다. 특히 Zig 프로그래밍 언어는 이제 매개변수로 전달되는 인터페이스로 I/O를 구현합니다(할당자 인터페이스도 마찬가지입니다). Go 역시 인터페이스를 광범위하게 사용하는 또 다른 예입니다.
이는 앱의 서로 다른 부분에 인터페이스 객체를 전달해야 한다는 뜻이지만, 인터페이스 작업을 단순화하기 위해 여러 기법을 쓸 수 있습니다. 한 가지 방법은 의존성 주입을 사용하는 것입니다. 위에서 데이터베이스 인스턴스를 PostsStore 인스턴스에 주입한 예를 보았습니다. 같은 방식을 UringMachine에도 적용할 수 있습니다. 머신 인스턴스를 HTTP 연결을 추상화하는 객체에 전달하는 것입니다.
class HTTPConnection
def initialize(machine, fd, &handler)
@fd = fd
@machine = machine
@handler = handler
end
def respond_empty(status = 200)
@machine.write(@fd, "HTTP/1.1 #{status}\r\nContent-Length: 0\r\n\r\n")
end
end
관련된 접근법으로 클로저를 사용하는 방법이 있으며, 이는 호출 가능 객체를 다룰 때 특히 유용합니다.
def make_posts_handler(posts_store)
->(req) {
posts = posts_store.all
req.respond_html(render_posts(posts))
}
end
app.start(&make_posts_handler(@posts_store))
인터페이스 객체 사용의 중요한 결과 중 하나는 앱을 더 책임감 있는 방식으로 구축하도록 장려한다는 것입니다. 예를 들어, 더 잘 알지 못했다면 뷰 템플릿 깊숙한 곳 어딘가에서 게시물을 읽거나 조작하고 싶은 유혹을 받을 수 있습니다. 하지만 이런 설계에서는 템플릿 코드가 PostsStore 인스턴스를 얻지 않는 한 그렇게 할 수 없으며, 그것은 나쁜 생각이고 금지되어야 합니다. 따라서 PostsStore 인터페이스에 대한 참조를 보유하지 않은 코드 부분은 데이터베이스를 건드릴 수 없도록 보장할 수 있고, 이는 머리카락을 더 많이 지키는 데 크게 기여할 것입니다!
하지만 ORM 이야기로 돌아갑시다. ORM에 없는 또 다른 기능은 준비된 문장을 사용할 수 있는 능력입니다. SQLite와 PostgreSQL에도 있다고 생각하는데, 준비된 문장은 미리 준비되어 애플리케이션이 반복해서 실행할 수 있는 쿼리입니다. 따라서 데이터베이스는 실행될 때마다 쿼리를 다시 파싱할 필요가 없습니다. 준비된 문장은 일시적이며 데이터베이스 연결이 유지되는 동안에만 존재합니다. SQLite에서는 실행 직후 폐기하는 대신 재사용하기 위해 메모리에 보관하는 일반 쿼리(SQLite 기술 용어로는 문장)일 뿐입니다.
대부분의 경우 유한한 수의 서로 다른 쿼리를 다루는데, 왜 데이터베이스가 같은 쿼리를 계속해서 반복 파싱해야 할까요? 이를 위해 준비된 문장을 사용할 수 있습니다. Extralite에는 준비된 문장(또는 쿼리)을 구현하는 Extralite::Query 클래스가 있지만, 최근에는 데이터베이스 수준에서 문장을 자동 캐싱하는 작업을 하고 있습니다. 즉 매개변수와 함께 실행되는 모든 쿼리는 캐시에 저장되고, 동일한 SQL이 Database#query에 주어질 때마다 자동으로 재사용됩니다.
코드는 아직 출시되지 않았습니다(바라건대 이달 말에는 준비될 것입니다). 성능에 어떤 영향을 미치는지 보기 위한 벤치마킹도 아직 하지 않았습니다. 이 기능을 사용하면 메모리 사용량은 약간 늘어납니다(준비된 각 쿼리는 수 KB의 RAM을 소비합니다). 하지만 CPU 시간과 할당은 줄어듭니다.
이것이 어디로 갈지, SQLite 데이터베이스에서 최대한을 끌어내려는 생각을 어디까지 밀어붙일 수 있을지 기대됩니다. 요즘 Ruby는 엄청나게 빠르고, 계속 더 빠르고 더 좋아지고 있습니다. 이제 낭비적이고 불필요한 추상화를 없애고, 더 빠르고 더 간결하며 더 나은 소프트웨어를 다시 작성하는 일에 나설 차례입니다.