안정적인 서비스 구축을 위해서는 여러 가지 요소가 뒷받침되어야 하지만 그중 중요한 요소 중 하나는 DB(데이터베이스)입니다. 사용자에게 최적화된 정보를 제공하기 위해서 회원 정보를 저장하거나 사용자가 원하는 데이터를 제공하기 위해 데이터를 저장하는 등 서비스에서 DB의 역할은 큰 비중을 차지하고 있습니다.
DB-Engines Ranking
Popularity ranking of database management systems.
db-engines.com
실제로 최근 랭킹을 보면 MySQL과 Oracle은 유지 또는 약간의 하락세를 보이지만 PostgreSQL 은 꾸준히 우상향함을 알 수 있습니다. 둘 다 오픈소스 DB이고, 둘 다 RDBMS이지만 왜 PostgreSQL 은 꾸준히 점유율이 올라가는 걸까요? 오늘은 오픈소스 RDBMS의 대표주자인 MySQL과 PostgreSQL을 비교하고 PostgreSQL의 점유율 증가 원인에 대해서 포스팅해보려고 합니다.
MySQL
스웨덴 소프트웨어 회사인 MySQL AB에 의해서 1995년에 개발되었습니다. 최초 MySQL AB 소유였지만 2008년 썬 마이크로시스템즈에 인수된 후, 2010년 오라클에 의해 썬 마이크로시스템즈가 인수되면서 현재는 오라클이 소유하고 있습니다.
- 오픈 소스: GNU GPL 라이선스를 따르며 누구나 무료로 다운로드하고 사용할 수 있습니다.
- 뛰어난 성능: 최적화된 쿼리 실행 엔진, 다중 스레드 처리, 인덱스 최적화를 통해 빠른 데이터 읽기와 쓰기 성능을 제공합니다.
- 다양한 운영체제 지원: OS에 종속되지 않고 다양한 환경에서 설치 및 운영이 가능합니다.
- 멀티스레드: 멀티스레드 아키텍처를 기반으로 동작하며 동시성 성능이 뛰어납니다.
- 다양한 스토리지 엔진: 다양한 엔진을 지원합니다. 기본 엔진 InnoDB 입니다.
로고가 돌고래인 이유는 MySQL AB의 창업자 중 하나인 울프 미카엘 "몬티" 비데니우스 가 돌고래의 빠르고 영리하고
친절해서 좋아했기 때문에 돌고래로 정했다고 합니다. 돌고래의 이름은 "돌고래 이름 짓기" 콘테스트에서 선정된 "Sakila"입니다.
PostgreSQL
- 오픈 소스 및 자유로운 라이선스: BSD 라이선스와 MIT 라이선스와 유사한 PostgreSQL 라이선스를 따르며 자유로운 소스 변경 및 배포가 가능합니다.
- ACID 준수 트랜잭션: 제한적인 조건에서 ACID 원칙을원칙을 준수하는 MySQL과 다르게 PostgreSQL은 모든 구성에서 ACID를 준수합니다.
- 객체-관계형 데이터베이스:관계형 데이터베이스(RDBMS)와 객체지향데이터베이스(OODBMS)의 특징을 결합한 객체관계형 데이터베이스입니다.
- 다양한 데이터 타입 지원: 기본적인 데이터 타입들뿐만 아니라 배열, hstore, JSON, JSONB 등을 포함한 다양한 데이터 유형을 지원합니다.
- PL/pgSQL 지원: SQL만으로는 표현하기 어려운 논리적 흐름, 반복문, 조건문 등을 포함한 복잡한 비즈니스 로직을 작성할 수 있는 PL/pgSQL을 지원합니다.
- 복잡한 트랜잭션 처리, 고급 쿼리, 사용자 정의 함수 등 다양한 기능을 활용이 필요하다면 PostgreSQL을 사용하는 것이 좋습니다.
- ex) 인터넷 뱅킹 시스템, 주식 거래 플랫폼
- 빠른 읽기 성능이 필요할 경우 읽기 중심의 작업에 최적화되어 있으며 빠른 인덱싱 시스템을 가지고 있어서 상대적으로 속도가 빠른 MySQL을 사용하는 것이 좋습니다.
- ex) 뉴스 포털, 온라인 쇼핑몰
- 동시에 많은 연결이 필요한 경우 수평적 확장이 뛰어난 MySQL을, 단일 노드의 성능 향상이 필요한 경우 수직적 향상이 뛰어난 PostgreSQL을 사용하는 것이 좋다고 알려져 있습니다.
- ex) 쇼핑몰, 실시간 채팅 서비스
- PostgreSQL 은 다양한 기능을 제공하고 고급 기능들을 제공하지만, MySQL에 비해서 상대적으로 적은 레퍼런스를 가지고 있으며 설정 및 관리가 더 복잡하기 때문에 더 높은 역량을 요구합니다.
성능 비교 테스트
프로세서: 13th Gen Intel(R) Core(TM) i7-13700HX 2.10 GHz
RAM: 32.0GB
PostgreSQL: 17.4
MySQL: 8.0.42
읽기(Read) 테스트
SELECT station_name, AVG(CAST(so2 AS FLOAT)) AS avg_so2
FROM public.air_quality_info
WHERE date BETWEEN '2020-01-01' AND '2025-12-31'
AND station_name = '서초구'
GROUP BY station_name;
결과 - PostgreSQL
GroupAggregate (cost=0.43..72377.45 rows=1 width=18) (actual time=13.878..13.879 rows=1 loops=1)
-> Append (cost=0.43..72062.29 rows=42020 width=16)
(actual time=0.039..7.366 rows=42386 loops=1)
-> Index Scan using p_2020_station_name_date_idx on air_quality_info_p_2020 air_quality_info_1
(cost=0.43..12827.30 rows=7522 width=16)
(actual time=0.038..1.632 rows=8784 loops=1)
Index Cond: (((station_name)::text = '서초구'::text) AND
(date >= '2020-01-01 00:00:00'::timestamp without time zone) AND
(date <= '2025-12-31 00:00:00'::timestamp without time zone))
-> Index Scan using p_2021_station_name_date_idx on air_quality_info_p_2021 air_quality_info_2
(cost=0.56..14267.24 rows=8358 width=16)
(actual time=0.038..1.249 rows=8760 loops=1)
Index Cond: (((station_name)::text = '서초구'::text) AND
(date >= '2020-01-01 00:00:00'::timestamp without time zone) AND
(date <= '2025-12-31 00:00:00'::timestamp without time zone))
-> Index Scan using p_2022_station_name_date_idx on air_quality_info_p_2022 air_quality_info_3
(cost=0.56..14327.55 rows=8372 width=16)
(actual time=0.024..0.975 rows=8760 loops=1)
Index Cond: (((station_name)::text = '서초구'::text) AND
(date >= '2020-01-01 00:00:00'::timestamp without time zone) AND
(date <= '2025-12-31 00:00:00'::timestamp without time zone))
-> Index Scan using p_2023_station_name_date_idx on air_quality_info_p_2023 air_quality_info_4
(cost=0.56..14505.75 rows=8464 width=16)
(actual time=0.014..1.047 rows=8760 loops=1)
Index Cond: (((station_name)::text = '서초구'::text) AND
(date >= '2020-01-01 00:00:00'::timestamp without time zone) AND
(date <= '2025-12-31 00:00:00'::timestamp without time zone))
-> Index Scan using p_2024_station_name_date_idx on air_quality_info_p_2024 air_quality_info_5
(cost=0.43..12299.91 rows=7171 width=16)
(actual time=0.018..0.828 rows=7321 loops=1)
Index Cond: (((station_name)::text = '서초구'::text) AND
(date >= '2020-01-01 00:00:00'::timestamp without time zone) AND
(date <= '2025-12-31 00:00:00'::timestamp without time zone))
-> Index Scan using p_2025_station_name_date_idx on air_quality_info_p_2025 air_quality_info_6
(cost=0.43..3624.42 rows=2133 width=15)
(actual time=0.011..0.011 rows=1 loops=1)
Index Cond: (((station_name)::text = '서초구'::text) AND
(date >= '2020-01-01 00:00:00'::timestamp without time zone) AND
(date <= '2025-12-31 00:00:00'::timestamp without time zone))
Planning Time: 0.534 ms
Execution Time: 13.919 ms
결과 - MySQL
-> Group aggregate: avg(cast(air_quality_info.SO2 as decimal(10,2)))
(cost=107681 rows=82831)
(actual time=99.1..99.1 rows=1 loops=1)
-> Filter: ((air_quality_info.station_name = '서초구') and
(air_quality_info.`date` between '2020-01-01' and '2025-12-31'))
(cost=99398 rows=82831) (actual time=0.0383..93.4 rows=42386 loops=1)
-> Index range scan on air_quality_info using station_date_index over
(station_name = '서초구' AND '2020-01-01 00:00:00' <= date <= '2025-12-31 00:00:00')
(cost=99398 rows=82831)
(actual time=0.0349..73.5 rows=42386 loops=1)
조회 쿼리는 MySQL 이 더 우수할 것으로 예상했지만 결과는 달랐습니다.
항목
|
MySQL 실행 계획
|
PostgreSQL 실행 계획
|
실행 계획 주요 노드
|
Group aggregate → Filter → Index range scan on station_date_index
|
GroupAggregate → Append → 파티션별 Index Scan
|
파티션 활용 여부
|
미활용 (단일 인덱스 사용)
|
활용 (파티션별 쿼리 실행)
|
인덱스 사용
|
단일 복합 인덱스(station_date_index)
|
각 파티션별 인덱스(p_YYYY_station_name_date_idx)
|
처리된 행 수
(actual rows) |
42,386 rows
|
42,386 rows (각 파티션별 8,784 + 8,760 + 8,760 + 8,760 + 7,321 + 1)
|
총 비용 (cost)
|
99,398
|
0.43 ~ 72,377 (파티션별 합산)
|
실제 실행 시간
(actual time) |
106 ms (총)
|
12 ms (총)
|
계획 수립 시간
(Planning Time) |
미제공
|
0.517 ms
|
인덱스 스캔 시간
|
약 77.4 ms (단일 인덱스)
|
파티션별 0.011 ~ 1.101 ms (병렬적 처리 가능)
|
필터링 방식
|
필터 후 집계
|
파티션별 필터 후 집계
|
요약
많은 레퍼런스에서 MySQL은 제공하는 기능이 PostgreSQL보다는 적지만 빠른 읽기 속도를 자랑한다는 내용이 지배적이었으나, 실제로 테스트해 본 결과 MySQL의 인덱스를 사용한 읽기 성능보다 PostgreSQL의 파티션을 활용한 읽기 성능이 훨씬 뛰어났습니다.
쓰기(Write) 테스트
PostgreSQL
DO $$
DECLARE
i INT := 0;
start_time TIMESTAMP := '2025-01-01 00:00:00';
BEGIN
WHILE i < 100000 LOOP
BEGIN
INSERT INTO air_quality_info (station_name, date, observation_type, SO2, CO, O3, NO2, PM10, PM25)
VALUES ('테스트', start_time - INTERVAL '1 hour' * i, '도로변대기', '0.1', '0.1', '0.1', '0.1', '0.1', '0.1');
COMMIT; -- 각 INSERT마다 COMMIT
END;
i := i + 1;
END LOOP;
END $$;
결과
MySQL
DROP PROCEDURE IF EXISTS insert_air_quality_data;
CREATE PROCEDURE insert_air_quality_data()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE start_time DATETIME DEFAULT NOW();
WHILE i < 100000
DO
INSERT INTO air_quality_info (station_name, date, observation_type, SO2, CO, O3, NO2, PM10, PM25)
VALUES ('테스트', DATE_SUB(start_time, INTERVAL i HOUR), '도로변대기', '0.1', '0.1', '0.1', '0.1', '0.1', '0.1');
SET i = i + 1;
END WHILE;
END;
CALL insert_air_quality_data();
결과
- autocommit 비활성화
- unique_checks 비활성화
- foreign_key_checks 비활성화
MySQL :: MySQL 8.4 Reference Manual :: 10.5.5 Bulk Data Loading for InnoDB Tables
10.5.5 Bulk Data Loading for InnoDB Tables These performance tips supplement the general guidelines for fast inserts in Section 10.2.5.1, “Optimizing INSERT Statements”. When importing data into InnoDB, turn off autocommit mode, because it performs a
dev.mysql.com
개선된 프로시져는 다음과 같습니다.
DELIMITER //
DROP PROCEDURE IF EXISTS insert_air_quality_data;
CREATE PROCEDURE insert_air_quality_data()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE start_time DATETIME DEFAULT NOW();
DECLARE original_unique_checks INT;
DECLARE original_foreign_key_checks INT;
DECLARE original_autocommit INT;
SELECT @@unique_checks, @@foreign_key_checks, @@autocommit
INTO original_unique_checks, original_foreign_key_checks, original_autocommit;
SET unique_checks = 0;
SET foreign_key_checks = 0;
SET autocommit = 0;
START TRANSACTION;
WHILE i < 100000 DO
INSERT INTO air_quality_info (station_name, date, observation_type, SO2, CO, O3, NO2, PM10, PM25)
VALUES ('테스트', DATE_SUB(start_time, INTERVAL i HOUR), '도로변대기', '0.1', '0.1', '0.1', '0.1', '0.1', '0.1');
SET i = i + 1;
END WHILE;
COMMIT;
SET unique_checks = original_unique_checks;
SET foreign_key_checks = original_foreign_key_checks;
SET autocommit = original_autocommit;
END //
DELIMITER ;
하나의 트랜잭션에서 10만 건의 데이터를 넣으니, 시간이 훨씬 단축되었습니다.
( 9분 29.295초 ➡️ 8.340초)
PostgreSQL 테스트에서 사용했던 BEGIN-END 구문은 트랜잭션의 사용할 때 사용하는 구문인데 현재 쿼리는 개선 전 MySQL 쿼리처럼 10만 개의 트랜잭션을 생성하고 있습니다. 그렇다면 PostgreSQL 도 하나의 트랜잭션에서 작업을 하면 더 빨라질까요?
DO $$
DECLARE
i INT := 0;
start_time TIMESTAMP := '2025-01-01 00:00:00';
BEGIN
WHILE i < 100000 LOOP
BEGIN
INSERT INTO air_quality_info (station_name, date, observation_type, SO2, CO, O3, NO2, PM10, PM25)
VALUES ('테스트', start_time - INTERVAL '1 hour' * i, '도로변대기', '0.1', '0.1', '0.1', '0.1', '0.1', '0.1');
COMMIT; -- 각 INSERT마다 COMMIT
END;
i := i + 1;
END LOOP;
END $$;
실행 번호
|
한 트랜잭션
|
개별 트랜잭션
|
Run 1
|
8 s 7715 ms
|
9 s 8781 ms
|
Run 2
|
8 s 8036 ms
|
11 s 10890 ms
|
Run 3
|
8 s 8349 ms
|
11 s 10719 ms
|
Run 4
|
9 s 8949 ms
|
11 s 10815 ms
|
Run 5
|
9 s 9128 ms
|
11 s 11008 ms
|
Run 6
|
9 s 9031 ms
|
11 s 11453 ms
|
Run 7
|
9 s 8951 ms
|
11 s 10717 ms
|
Run 8
|
8 s 8498 ms
|
10 s 10453 ms
|
Run 9
|
9 s 9435 ms
|
11 s 10571 ms
|
Run 10
|
9 s 9069 ms
|
11 s 10814 ms
|
평균 시간
|
8 s 2716 ms
|
10 s 4622 ms
|
미세한 차이지만 하나의 트랜잭션에서 작업을 처리하는 쿼리가 더 빠른 성능을 보여주는 것을 알 수 있었습니다.
요약
BULK INSERT 시 MySQL은 입력데이터 건수마다 트랜잭션을 생성하는 반면 PostgreSQL 은 하나의 트랜잭션에서 모든 데이터를 삽입했기 때문에 더 우수한 성능을 보여줬습니다. 하지만 MySQL도 약간의 쿼리 튜닝을 통해 성능을 향상할 수 있었습니다.
추가 테스트
MySQL
EXPLAIN ANALYZE
WITH RECURSIVE recent_months AS (
SELECT 1 AS step,
'서초구' AS station_name,
DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00') AS date_time
UNION ALL
SELECT step + 1,
station_name,
DATE_SUB(date_time, INTERVAL 1 MONTH )
FROM recent_months
WHERE step < 1000
)
SELECT *
FROM air_quality_info aqi
JOIN recent_months months on aqi.date = months.date_time
WHERE O3 >= 0.3
PostgreSQL
EXPLAIN ANALYZE
WITH RECURSIVE recent_months AS (
SELECT
1 AS step,
'서초구' AS station_name,
date_trunc('hour', CURRENT_TIMESTAMP) AS date_time
UNION ALL
SELECT
step + 1,
station_name,
date_time - INTERVAL '1 month'
FROM recent_months
WHERE step < 1000
)
SELECT *
FROM air_quality_info aqi
JOIN recent_months months on aqi.date = months.date_time
WHERE
o3 != '-'
AND CAST(o3 as float) >= 0.3
MySQL 결과
-> Nested loop inner join (cost=747 rows=226) (actual time=356..12554 rows=209 loops=1)
-> Filter: (months.date_time is not null) (cost=2.9..2.84 rows=3)
(actual time=0.995..1.72 rows=1000 loops=1)
-> Table scan on months (cost=4.19..5.45 rows=2) (actual time=0.995..1.46 rows=1000 loops=1)
-> Materialize recursive CTE recent_months (cost=2.93..2.93 rows=2)
(actual time=0.994..0.994 rows=1000 loops=1)
-> Rows fetched before execution (cost=0..0 rows=1) (actual time=0..0 rows=1 loops=1)
-> Repeat until convergence
-> Filter: (recent_months.step < 1000) (cost=2.73 rows=1)
(actual time=0.0015..0.0832 rows=500 loops=2)
-> Scan new records on recent_months (cost=2.73 rows=2)
(actual time=850e-6..0.0287 rows=500 loops=2)
-> Filter: ((aqi.O3 >= 0.12) and (aqi.`date` = cast(months.date_time as datetime(6))))
(cost=228 rows=75.2) (actual time=11.4..12.6 rows=0.209 loops=1000)
-> Index lookup on aqi using date_station_index (date=months.date_time)
(cost=228 rows=226) (actual time=0.117..12.5 rows=102 loops=1000)
PostgreSQL 결과
Nested Loop (cost=12.49..977361.29 rows=3848471 width=124) (actual time=37.673..603.789 rows=209 loops=1)
CTE recent_months
-> Recursive Union (cost=0.00..2.73 rows=31 width=44) (actual time=0.005..1.344 rows=1000 loops=1)
-> Result (cost=0.00..0.01 rows=1 width=44) (actual time=0.004..0.004 rows=1 loops=1)
-> WorkTable Scan on recent_months (cost=0.00..0.24 rows=3 width=44)
(actual time=0.000..0.001 rows=1 loops=1000)
Filter: (step < 1000)
Rows Removed by Filter: 0
-> CTE Scan on recent_months months (cost=0.00..0.62 rows=31 width=44)
(actual time=0.006..1.603 rows=1000 loops=1)
-> Append (cost=9.76..31497.69 rows=2999 width=81) (actual time=0.549..0.601 rows=0 loops=1000)
-> Bitmap Heap Scan on air_quality_info_p_2001 aqi_1 (cost=9.76..592.48 rows=51 width=92)
(actual time=0.832..0.832 rows=0 loops=12)
Recheck Cond: (date = months.date_time)
Filter: ((o3 <> '-'::text) AND ((o3)::double precision >= '0.12'::double precision))
Rows Removed by Filter: 172
Heap Blocks: exact=2092
-> Bitmap Index Scan on p_2001_date_station_name_idx (cost=0.00..9.75 rows=176 width=0)
(actual time=0.024..0.024 rows=174 loops=12)
Index Cond: (date = months.date_time)
-> Bitmap Heap Scan on air_quality_info_p_2002 aqi_2 (cost=9.91..660.34 rows=57 width=92)
(actual time=0.880..0.880 rows=0 loops=12)
Recheck Cond: (date = months.date_time)
Filter: ((o3 <> '-'::text) AND ((o3)::double precision >= '0.12'::double precision))
Rows Removed by Filter: 193
Heap Blocks: exact=2335
-> Bitmap Index Scan on p_2002_date_station_name_idx (cost=0.00..9.90 rows=196 width=0)
(actual time=0.024..0.024 rows=195 loops=12)
Index Cond: (date = months.date_time)
//중략...
-> Bitmap Heap Scan on air_quality_info_p_2024 aqi_24 (cost=29.50..2240.01 rows=214 width=75)
(actual time=3.949..4.213 rows=2 loops=12)
Recheck Cond: (date = months.date_time)
Filter: ((o3 <> '-'::text) AND ((o3)::double precision >= '0.12'::double precision))
Rows Removed by Filter: 578
Heap Blocks: exact=7168
-> Bitmap Index Scan on p_2024_date_station_name_idx (cost=0.00..29.45 rows=669 width=0)
(actual time=0.091..0.091 rows=599 loops=12)
Index Cond: (date = months.date_time)
-> Bitmap Heap Scan on air_quality_info_p_2025 aqi_25 (cost=24.63..1397.76 rows=186 width=88)
(actual time=2.930..2.930 rows=0 loops=5)
Recheck Cond: (date = months.date_time)
Filter: ((o3 <> '-'::text) AND ((o3)::double precision >= '0.12'::double precision))
Rows Removed by Filter: 355
Heap Blocks: exact=1777
-> Bitmap Index Scan on p_2025_date_station_name_idx (cost=0.00..24.59 rows=572 width=0)
(actual time=0.094..0.094 rows=355 loops=5)
Index Cond: (date = months.date_time)
Planning Time: 2.707 ms
Execution Time: 604.296 ms
항목
|
MySQL 실행 계획
|
PostgreSQL 실행 계획
|
조인 방식
|
Nested Loop Inner Join (Index Lookup)
|
Nested Loop
|
Recursive CTE 처리
|
Materialize recursive CTE로 1000건 생성 (1회 실행)
|
Recursive Union으로 1000건 생성 (1회 실행)
|
CTE 결과 접근
|
Table Scan on months
|
CTE Scan on recent_months months
|
원본 테이블 접근 방식
|
Index Lookup using
|
파티션별 Bitmap Index Scan + Bitmap Heap Scan
|
데이터 분할 구조
|
단일 테이블에서 인덱스를 활용
|
연도별 파티션(air_quality_info_p_YYYY) 총 20개 이상
|
루프 수
|
외부 루프 1, 내부 루프 1000 (month 수만큼)
|
외부 루프 1, 내부 루프 1000
|
실제 반환 행 수
|
209 rows
|
209 rows
|
실행 시간
|
12 s 554ms
|
0 s 603 ms
|
I/O 비용
|
Index Lookup 반복 및 필터링
|
Bitmap Index Scan → Heap Scan 반복으로 파티션 병렬 처리 가능
|
결론
여담
- 테이블을 샤딩해서 여러 개의 데이터 노드로 분산시키고 coordinate 노드 분산 처리하여 더 빠른 시간 안에 실행할 수 있음
- PostgreSQL의
- 수평확장이 약한 PostgreSQL의 단점을 Citus의 병렬 쿼리 처리로 보완할 수 있음
- 다양한 샤딩 전략 및 고가용성 구성이 용이함
Citus Data | Distributed Postgres. At any scale.
Citus gives you all the greatness of Postgres plus the superpowers of distributed tables. By distributing your data and queries, your application gets high performance—at any scale. The Citus database is available as open source and as a managed service
www.citusdata.com
'Tech lab' 카테고리의 다른 글
AI를 제조업에서 사용하다면? (feat. Cropper) (2) | 2025.05.09 |
---|---|
데이터 파이프라인 모니터링 시스템 구축 (0) | 2025.04.04 |
n8n에서 파이썬(Python)을 활용하여 데이터파이프라인 구축하기 (1) | 2025.03.28 |