현대의 백엔드 개발자들(심지어 DB를 다루는 프론트엔드 개발까지)은 기본적으로 ORM(Object-Relational Mapper) 사용을 널리 받아들이고 있습니다. Python 생태계에서도 금쪽같은 Django의 ORM, 혹은 SQLAlchemy 가 있어서 Python 개발자들에게 정말 많은 편리함을 제공하고 있습니다.
하지만 "지옥으로 가는 길은 선의로 포장되어 있다" 라는 유명한 격언처럼, ORM 이 제공하는 편리함 속에는 함정이 숨어있습니다. 대표적인 문제인 N+1 현상은 널리 알려져있는 반면, 무심코 날리고 있는 SELECT * 쿼리와 Fat Model 문제가 성능에도 영향을 끼친다는 사실은 종종 무시 당하는것 같습니다.
성능에 집착하는 개발자 중 한명으로서 ORM 의 또다른 함정을 명확히 정리하고 해결하는 방법을 정리하는 글이 필요하다고 생각했고, 이러한 이유로 이 글을 씁니다.
SELECT * 를 쓰면 왜 안좋은가?
이 말은 왜 더 적은 칼럼을 가져올 수록 성능이 좋아지는가?와 같은 말입니다. 그리고 ORM 을 어느정도 사용해본 개발자라면 "특정 칼럼만 가져오면 더 빨라지던데?" 와 같은 경험을 해보셨을 겁니다. 하지만 도대체 왜 빨라지는 걸까요?
우선 SELECT * 가 아니라 SELECT {some_columns} 를 사용하면 세 개의 서로 다른 구간에서 성능 개선이 일어납니다. 네트워크 영역과 백엔드 서버 그리고 데이터베이스 서버입니다.
1. 네트워크에서의 성능 개선
네트워크 레벨에서의 성능 개선도 쉽게 짐작할 수 있습니다. 메모리 레벨에서 Applicaiton 서버와 Database 서버간 데이터 교환이 일어나는 것이 아니라면 대부분 네트워크를 거치게 됩니다. 더 적은 칼럼을 조회하면 당연히 네트워크 트래픽도 줄어들기 때문에 성능 개선이 일어나는 것이죠.
2. 백엔드 서버에서의 성능 개선
ORM 을 사용하는 백엔드 서버에서는 어떤 성능 개선이 일어날까요? 생각보다 아주 간단한데요. 바로 메모리 사용 최적화입니다. 일반적으로 Value Object 를 만드는것보다 ORM Object 를 만드는 것이 더 많은 오버헤드를 발생시킵니다. 즉, 특정 필드만 가져오도록 해서 ORM Object 가 아니라 Value Object 를 메모리에 적재하게 된다면 그 만큼 성능 최적화가 일어난다는 것이죠. 이러한 성능 최적화는 여러 요청을 동시에 처리하는 백엔드 서버가 대량의 데이터를 한번에 많이 가져와야하는 경우 더욱 두드러지게 나타납니다.
3. 데이터베이스 서버에서의 성능 개선
데이터베이스에서 성능 개선이 일어나는 부분은 백엔드 서버보다 조금 더 깊은 이해를 필요로 합니다. 하지만 이 글을 이해하기 위해 RDBMS 의 구조를 다 뜯어보고 그 설계철학을 모두 이해하기 위해 노력할 필요는 없습니다. 필요한 개념만 간단히 정리하겠습니다.

- 데이터베이스에 캐시가 존재합니다. '버퍼 풀', '공유 버퍼' 라고 합니다. 이러한 캐시에는 쿼리 결과값 뿐만 아니라 인덱스도 저장됩니다.
- 데이터베이스에서 I/O 는 물리적 I/O 와 논리적 I/O 로 구분 됩니다. 물리적 I/O 는 Disk I/O 를 의미하며, 논리적 I/O 는 버퍼 풀에서 데이터를 복제하는 과정을 의미합니다.
- 데이터베이스는 디스크에 데이터를 페이지 단위로 저장합니다. 이러한 페이지는 행의 데이터를 열의 순서에 맞게 저장합니다. 무슨 말이냐면 물리적 I/O 가 일어날때 일부 Column 만 선택해도 전체 Row 를 페이지 단위로 읽어오면서 메모리에 적재한다는 것입니다. 그리고 메모리와 캐시에 적재되는 데이터로 페이지 단위입니다.
이러한 사실을 바탕으로 하나의 쿼리가 데이터베이스 서버로 들어오고 결과값이 출력될때까지의 과정을 그려보자면 아래와 같습니다.
[쿼리] -> [캐시 에서 조회]
- 있으면 -> [논리 I/O, 페이지 단위] -> [결과 반환]
- 없으면 -> [물리 I/O, 페이지 단위] -> [캐시 저장(논리 I/O, 페이지 단위)] -> [결과 반환]
캐시Miss 가 발생하여 물리I/O 가 발생하면 적어도 캐시 Hit 시나리오보다 적어도 2배(현실은 수백배~수천배 더 느림)는 더 느립니다. 즉, 동일한 쿼리에 대해서 데이터베이스의 물리 I/O 를 최대한 적게 만드는 것이 데이터베이스 조회 성능을 끌어오는 핵심입니다. 그렇다면 우리는 최대한 인덱스 위주의 쿼리 결과값 조회 혹은 캐시 영역에 유효한 데이터가 오래 남도록 해야합니다.
SELECT * 와 SELECT {some_columns} 사이에는 어떤게 직접적으로 물리 I/O를 적게 일으키냐 결정할 수 없습니다. 칼럼 설계를 잘못해서 연속적이지 않은 칼럼을 조회할 경우 SElECT * 와 SELECT {some_columns} 가 물리 I/O 를 만들어내는 부하는 똑같을 것입니다.
하지만 캐시에 적재되는 순간부터는 완전히 다른 이야기가 됩니다. 물리 I/O 과정을 통해 디스크에서 나온 수많은 페이지들이 메모리에 적재되어 정제된 후 캐시에 들어갈때는 훨씬 작은 페이지가 되기 때문입니다. 만약 SELECT * 를 하고, 해당 테이블에 BLOB 이나 TEXT 타입의 데이터가 있고 해당 칼럼은 관심이 있는 칼럼이 아니라면? 캐시 메모리를 굉장히 비효율적으로 쓰게 될 것입니다. 반면 SELECT {some_columns} 를 통해 필요한 칼럼만 캐시에 적재한다면 캐시 메모리를 더 효율적으로 쓰게되어 더 많은 유효한 데이터를 더 오랫동안 캐시에 모아둘 수 있게 됩니다. (새로운 데이터를 캐싱해야할때 캐시 메모리가 부족하다면 가장 참조가 덜된 데이터를 밀어내기 때문에)

그리고 캐시에는 인덱스 정보가 저장되기 때문에 인덱스에 맞는 특정 칼럼만 조회하는 것으로 성능 최적화(커버링 인덱스)를 만들어 낼 수도 있습니다.
Q. 무슨 데이터베이스를 기준으로 말하는 건가요?
A. 현대에는 여러 종류의 데이터베이스가 존재합니다. SQL, NoSQL 혹은 Row-Oriented, Column-Oriented 혹은 OLTP, OLAP 여러 조건들을 고려해야 할것입니다. 하지만 이 글은 일반적으로 많이 사용하는 SQL, Row-Oriented, OLTP 데이터베이스를 대상으로 작성되었습니다. 더 구체적으로 말하자면 MySQL InnoDB, PostgreSQL 을 생각하면서 글을 썼습니다.
Q. 데이터베이스 구조 설명이 너무 러프한데요?
A. 우리가 일반적으로 많이 사용하는 MySQL, PostgreSQL 과 같은 OLTP 용 RDBMS 의 엄밀한 구조는 공식문서나 인터넷, 혹은 서적에서 더 엄밀하고 양질의 자료를 찾을 수 있습니다. 이 글에서는 "칼럼을 적게 선택(SELECT) 할 수록 성능이 좋아지는 이유" 에 집중해서 설명하기 위해 데이터베이스 구조를 많이 단순화헀습니다.
어떻게 최적화 할 것인가?
왜 더 적은 칼럼을 조회할 수록 성능이 개선되는지 알게 되었습니다. 그럼 어떻게 성능을 최적화 할 수 있을 까요?
1. ORM 에서 특정 칼럼만 가져오기
Python 의 경우 아래와 같이 ORM 을 이용하면 모든 칼럼을 조회 (SELECT *) 하게 됩니다.
# Django
users = User.objects.all()
# SELECT "user"."id", "user"."username", "user"."email", "user"."bio", ... FROM "user"
# SQLAlchemy
users = session.query(User).all()
# SELECT user.id, user.username, user.email, user.bio, ... FROM user
반면 Django의 경우 .only() 혹은 .values() 를 이용하거나 SQLAlchemy 는 .options(load_only()) 와 query({column_names...}) 를 이용해서 특정 칼럼만 가져올 수 있습니다.
왜 하나의 방법이 아닌 두개의 방법을 제공하는 걸까요? .only 와 .options(load_only()) 는 ORM 객체를 유지한다는 특징이 있습니다. 오...그러면 좋은걸까요? 백엔드 서버단에서의 성능 개선은 애매하지만 일단 데이터베이스와 네트워크에서의 성능 개선은 달성 하니까요? 일단 맞습니다. 하지만 일단 ORM 객체를 만들어주기 때문에 누구든지 load 되지 않은 필드에 접근을 한다면 아래와 같이 Deferred loading 현상을 만들어낼 위험이 존재합니다.
# SELECT "user"."id", "user"."username" FROM "user"
# Django ORM
users = User.objects.only('id', 'username')
for user in users:
print(user.username)
print(user.email) # 추가 쿼리 발생 (Deferred loading)
# SQLAlchemy
users = session.query(User).options(load_only(User.id, User.username)).all()
for user in users:
print(user.username)
print(user.email) # 추가 쿼리 발생 (Deferred loading)
반면 values() 와 query({column_names...}) 는 ORM 객체가 아니라 dict 를 반환하게 됩니다. ORM 특징적인 기능은 사용하지 못하겠지만 불편한 만큼 성능상 이득과 더 명확히 side-effect 를 막는 효과가 있습니다.
# SELECT "user"."id", "user"."username" FROM "user"
# Django ORM
users = User.objects.values('id', 'username')
# users[0] = {'id': 1, 'username': 'alice'}
# SQLAlchemy
users = session.query(User.id, User.username).all()
# users[0] = (1, 'alice')
2. 명확한 관심사 분리와 수직분리

그렇다면 특정 필요로 하는 칼럼만 dict 로 만들면 모든 문제가 해결될까요? 아쉽게도 그렇지 않습니다. 데이터베이스에서 물리 I/O 가 아예 발생하지 않을 수 없습니다. 그리고 물리 I/O 가 발생할때 최종적으로 반환하려는 칼럼들이 서로 떨어진 페이지에 저장되어 있다면 그 구간의 모든 페이지를 모두 디스크에서 읽어와야하는 불상사가 발생합니다. 그리고 무엇보다 dict 로 데이터를 반환받을거면 ORM 을 왜 씁니까!
즉, 개발 조직의 생산성을 떨어트리지 않으면서 성능도 챙길 수 있는 방법이 필요합니다. 기본값(default) 자체가 빠르도록 RDBMS 설계를 변경하는 것이 더 근본적인 해결책입니다. 각 테이블마다 명확한 관심사를 갖도록 설계하고, 관심사가 동일하더라도 Usecase상 접근 빈도가 더 낮으면서 디스크공간을 많이 차지하는 데이터들을 수직분할(Vertical Partitioning) 하는 방법을 적용하면 해결됩니다. 정말 RDBMS에서 테이블 설계가 잘 되었다면 모든 쿼리를 SELECT * 로 만들어도 큰 이슈가 없을 것입니다.
이렇게 까지 해야하나요?
빠른 속도의 장점은 종종 과소 평가 됩니다. "우리 고객들은 빠른 네트워크 환경에 있는데 100~200ms 정도 더 차이나는게 대수일까?" 하는 생각 때문일텐데요. 이러한 생각이 갖는 문제는 해당 조직의 성능 기준을 처음에는 100ms, 150ms 정도 더 느려지는 것을 용인하는 수준에서 나중에는 1000ms, 1500ms 까지 방치되는 수준으로 퇴화시키는 시작점이기 때문입니다.
그렇다면 조직 내에 빡빡한 성능 기준을 만들어야 할만큼 빠른 속도는 정말 중요할까요? Erricson의 보고서(2016), BBC의 사례(2018), Rakuten의 사례(2022) 에 따르면 사이트 로딩 속도가 느리면 사용자는 스트레스를 심하게 받으며, 이탈로 이어집니다. 반대로 사이트 로딩 속도가 빠르면 전환율이 오르기도 합니다. 즉, 속도는 고객의 이탈과 전환율에 직접적으로 관여하는 요소입니다.
제가 만난 많은 개발자들은 언제나 비즈니스 임팩트까지 만들어내고 싶어하는 분들이었습니다. 그런 개발자들의 고민은 "어떻게 고객들이 원하는 삐까뻔쩍한 기능을 만들까?" 에만 국한되어서는 안됩니다. 가장 기본이라고 할 수 있는 성능 즉, 속도를 빠르게 하는 것만으로도 정말 유의미한 비즈니스 임팩트를 만들어낼 수 있습니다.
저는 처음 개발자 커리어를 시작할 때부터 수억건의 데이터를 가진 N 개의 DB 테이블을 활용해서 200ms 내에게 데이터를 서빙하는데 주의를 기울였습니다. 이 속도를 Latency 를 1000ms 에서 500ms 로, 200ms 로, 100ms 이내로 줄일때마다 고객들이 명확하게 이를 인지하고 더 기쁜 마음으로 서비스를 사용한다는 사실을 눈앞에서 보았습니다. 저는 이러한 경험을 통해 성능에 집착하는 개발자가 되었습니다. 우리 모두 더 좋은 사용자 경험과 이를 통해 비즈니스 임팩트를 만들어 내는 개발자가 되어봅시다.