Ruby Sinatra 앱의 SQLite에서 MySQL 및 PostgreSQL로의 데이터베이스 마이그레이션 및 최적화 여정

How I ran one Ruby app on three SQL databases for six months – mackuba.eu

작성자
Ruby Weekly
발행일
2025년 10월 15일

핵심 요약

  • 1 Ruby(Sinatra) 기반의 Bluesky 피드 서비스는 초기 SQLite 사용으로 인한 동시 쓰기 문제와 성능 한계에 직면했습니다.
  • 2 MySQL과 PostgreSQL로의 마이그레이션 과정에서 다양한 데이터베이스별 구문 및 타입 변경, 데이터 정제, 시간대 처리 등의 난관을 극복했습니다.
  • 3 두 데이터베이스를 비교 테스트하고 PostgreSQL의 인덱스 최적화와 VACUUM 작업을 통해 성능을 개선, 최종적으로 PostgreSQL을 선택하여 안정적인 서비스 운영을 달성했습니다.

도입

2023년 6월부터 Ruby(Sinatra)로 개발된 Bluesky 커스텀 피드 서비스는 매일 수백만 건의 게시물을 처리하며 빠르게 성장했습니다. 초기에는 개발 편의성을 위해 SQLite를 데이터베이스로 사용했으나, 하루 수 기가바이트에 달하는 데이터 증가와 초당 수천 개의 게시물을 저장해야 하는 요구사항으로 인해 SQLite의 근본적인 한계에 부딪히게 되었습니다. 특히 단일 쓰기 접근만 허용하는 SQLite의 특성상 동시성 문제가 발생하기 시작했고, 이는 서비스의 안정성과 확장성에 심각한 제약으로 작용했습니다.

SQLite의 한계와 ActiveRecord 동시성 문제

서비스는 Firehose 스트림 소비자 프로세스를 통해 게시물을 저장하고 Sinatra API 서버는 저장된 데이터를 조회하는 단일 쓰기 아키텍처를 가졌습니다. 그러나 PLC 디렉토리 데이터 저장 스레드 및 다양한 Cron 작업이 추가되면서 SQLite의 동시 쓰기 제한은 SQLite3::BusyException 오류로 이어졌습니다. ActiveRecord가 트랜잭션 및 잠금 모드를 처리하는 방식의 문제로, 읽기 잠금 후 쓰기 잠금으로 전환하려 할 때 다른 쓰기가 발생하면 즉시 예외가 발생했습니다. 이를 회피하기 위해 FeedPost.where(feed_id: -1).update_all(feed_id: -1)와 같은 인위적인 쓰기 작업을 먼저 수행하거나, Post.where(id: @post.id).update_all(thread_id: root.id)와 같이 모델 객체를 직접 건드리지 않는 방식으로 코드를 수정해야 했습니다. 아이러니하게도 ActiveRecord 8.0에서 이 문제가 해결되었지만, 필자는 이미 마이그레이션을 완료한 시점이었습니다.

MySQL 및 PostgreSQL로의 마이그레이션 과정

SQLite의 한계와 성능 문제를 해결하기 위해 MySQL과 PostgreSQL로의 전환을 시도했습니다. 이 과정에서 여러 도전 과제에 직면했습니다.

  • 컬럼 타입 변경: SQLite의 유연한 숫자 및 문자열 타입과 달리, MySQL에서는 smallint, bigint, text 등으로 명시적인 타입 변경이 필요했으며, datetime 컬럼의 정밀도 설정도 중요했습니다.

  • 쿼리 재작성: +thread_id IS NULL과 같은 SQLite 특정 인덱스 힌트를 제거하고, DATETIME('now', '-7 days')SUBDATE(CURRENT_TIMESTAMP, 7)로, DATE_SUBTRACT(CURRENT_TIMESTAMP, INTERVAL '7 days')와 같이 각 데이터베이스의 날짜 함수에 맞춰 쿼리를 수정했습니다. 또한 SELECT 절에 테이블 이름을 명시하거나 DELETE 쿼리에서 서브쿼리를 중첩하는 등 구문 오류를 해결해야 했습니다.

  • 데이터 마이그레이션 도구: MySQL로의 마이그레이션에는 Python 기반의 sqlite3mysql 도구를 사용했으며, PostgreSQL에는 pgloader를 활용했습니다. 대용량 posts 테이블은 배치 처리 옵션을 통해 마이그레이션했습니다.

  • 예상치 못한 데이터 문제: SQLite가 컬럼 길이 제한을 강제하지 않아, 일부 레코드의 문자열 길이가 예상보다 길어 새로운 데이터베이스에서 거부되는 문제가 발생했습니다. 이는 Ruby 측의 validates_length_of 유효성 검사 누락 때문이었습니다. MySQL에서는 악센트 및 유니코드 정규화 규칙 차이로 인해 해시태그 테이블의 고유 인덱스 충돌 문제가 발생했고, PostgreSQL에서는 널 바이트를 포함한 문자열 처리 문제가 나타나 해당 게시물을 필터링해야 했습니다.

  • 시간대(Timezone) 문제: PostgreSQL에서는 ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz 설정을 통해 timestamptz 타입을 사용하도록 변경했으며, MySQL에서는 ActiveRecord.default_timezone = :local 또는 데이터베이스 자체의 default-time-zone = '+00:00' 설정을 통해 시간대 문제를 해결했습니다.

최적화 및 비교 테스트

두 데이터베이스의 성능을 공정하게 비교하기 위해 A/B 테스트 방식으로 트래픽을 분산했습니다. 초기에는 특정 쿼리에서 MySQL 또는 PostgreSQL이 지연되는 문제가 발생했으며, 이는 주로 누락된 인덱스를 추가하거나 복합 인덱스의 필드를 조정하고 쿼리 구조를 변경하여 해결했습니다. 특히 PostgreSQL 버전에서는 ‘replies feeds’의 특정 쿼리 성능 개선에 많은 시간을 할애했습니다. (user, time) 인덱스를 (user, time DESC, id)로 변경하여 ‘Index Only Scans’가 가능하도록 했으며, 빈번한 VACUUM 작업을 cron job으로 설정하여 인덱스 정리를 강제했습니다. 이러한 최적화 후, PostgreSQL은 해당 쿼리 응답 시간을 20ms 미만으로 단축하여 MySQL의 50ms보다 우수한 성능을 보였습니다.

결론

수개월간의 튜닝과 최적화 끝에 MySQL과 PostgreSQL 모두 서비스 요구사항을 만족할 만한 수준에 도달했습니다. 최종적으로 PostgreSQL이 선택되었는데, 이는 PostgreSQL이 제공하는 다양한 설정 옵션과 상세한 쿼리 분석기(EXPLAIN) 출력 덕분에 시스템을 더 '제어'할 수 있다는 느낌을 받았기 때문입니다. MySQL이 `innodb_buffer_pool_size` 설정 외에 추가 최적화에 대한 가이드가 부족했던 반면, PostgreSQL은 더 많은 '다이얼과 스위치'를 제공하여 세부적인 튜닝이 가능했습니다. 성능 벤치마크 결과, PostgreSQL 서버는 디스크 읽기 IO/처리량에서, MySQL 서버는 디스크 쓰기 IO/처리량에서 높은 수치를 보였지만, 미래의 트래픽 증가를 고려할 때 PostgreSQL의 읽기 부하 확장성이 더 유리할 것으로 판단했습니다. 또한, 핵심 'replies' 피드 쿼리 성능과 최대 속도로 이벤트를 처리할 때의 삽입 처리 속도 역시 PostgreSQL 버전이 약간 더 우수했습니다. 2025년 3월부터 이 서비스는 PostgreSQL 데이터베이스를 사용하는 새로운 VPS에서 안정적으로 운영되고 있습니다.

댓글 0

댓글 작성

0/1000
정중하고 건설적인 댓글을 작성해 주세요.

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!