본문 바로가기
CS/개인 프로젝트

SQLite 하나로 시작해 6개 파일로 나누기까지: 개인 서비스의 데이터 분리 기준

by lms0806 2026. 10. 19.
728x90
반응형

개인 프로젝트에서 SQLite를 쓰기로 한 이유는 단순합니다. 운영할 서버가 한 대이고, 관계형 조회와 배치 저장이 필요하며, 별도 DB 서버를 관리하고 싶지 않았습니다. 처음에는 주 데이터베이스 파일 하나면 충분했습니다.

 

문제는 서비스가 자라며 테이블의 성격이 달라졌다는 점입니다. 사용자 프로필과 로그인 세션은 요청마다 비교적 작게 읽고 씁니다. 반면 통계, 활동 분석, 검색 집계, 상품 카탈로그는 갱신 주기·데이터량·쓰기 패턴이 모두 다릅니다. 한 파일에 계속 쌓아도 당장 망가지지는 않지만, 배치 작업과 웹 요청이 같은 쓰기 경로를 경쟁하고 백업·이전·장애 원인을 분리하기도 어려워집니다.

 

그래서 데이터베이스 서버를 도입하는 대신, SQLite 파일을 데이터의 운영 성격에 따라 나누었습니다.

최종 구조

파일 책임 주된 쓰기 주체
main.db 일반 사용자 데이터·세션 웹 요청
analytics.db 통계 스냅샷 통계 수집 배치
activity.db 활동 분석 결과 분석 배치
counters.db 검색·조회 횟수 웹 요청
catalog-a.db 첫 번째 상품 카탈로그 카탈로그 갱신 배치
catalog-b.db 두 번째 상품 카탈로그 아이템 갱신 배치

여기서 중요한 기준은 테이블 이름이나 도메인 모델의 예쁨이 아니었습니다. 어떤 작업이, 얼마나 자주, 얼마나 오래 쓰는가였습니다. 예를 들어 카탈로그 갱신은 한 번에 많은 행을 다루지만 사용자 요청과 같은 쓰기 큐를 공유할 이유가 없습니다. 파일을 나누면 한 작업의 쓰기 대기가 다른 도메인의 쓰기를 직접 막지 않습니다.

 

SQLite 파일이 여섯 개가 되었다고 해서 여섯 개의 독립 서비스를 운영하는 것은 아닙니다. Rust/Axum 프로세스 하나가 SqlitePool 여섯 개를 들고 있고, 각 파일은 자신의 스키마와 마이그레이션을 가집니다.

Web API process
├─ main.db                     일반 서비스 데이터
├─ analytics.db                통계 배치 데이터
├─ activity.db                 활동 분석 배치 데이터
├─ counters.db                 검색 집계
├─ catalog-a.db                카탈로그 A
└─ catalog-b.db                카탈로그 B

SQLite의 단일 writer를 코드의 규칙으로 만들기

SQLite는 동시에 읽는 작업에는 강하지만, 한 데이터베이스 파일에는 한 번에 하나의 writer만 둘 수 있습니다. 연결 풀의 크기를 키운다고 쓰기 병렬성이 생기지 않습니다. 오히려 여러 요청이 동시에 쓰기를 시도할 때 database is locked를 만날 확률만 높아질 수 있습니다.

 

이 프로젝트에서는 세 가지를 같이 적용했습니다.

  1. 모든 연결에 WAL 모드와 synchronous=NORMAL을 설정했습니다. 웹 서비스에서 읽기와 쓰기가 함께 일어나는 상황에서 읽기 병행성을 확보하기 위한 선택입니다.
  2. busy_timeout을 5초로 둬, 잠깐의 쓰기 경합을 즉시 실패로 만들지 않습니다.
  3. 각 DB 파일별로 tokio::sync::Mutex를 두고, 쓰기는 앱 레벨에서 명시적으로 직렬화합니다.
// DB 파일마다 별도의 writer lock을 둡니다.
pub async fn with_cash_db_write<R>(&self, fut: impl Future<Output = R>) -> R {
    let _guard = self.cash_db_write_lock.lock().await;
    fut.await
}

풀은 파일별 최대 4개 연결로 작게 유지했습니다. 이는 "4개의 동시 쓰기"를 뜻하지 않습니다. 읽기 쿼리와 마이그레이션, 대기 중인 작업을 수용하되, 쓰기는 lock 뒤에서 한 번에 하나만 실행하겠다는 의미입니다.

 

이 규칙은 run_write, run_cash_write처럼 저장소 계층의 helper로 감쌌습니다. 핸들러마다 락을 직접 잡도록 두면 빠진 경로가 생기기 쉽고, 락을 잡은 상태에서 다시 같은 helper를 호출해 데드락을 만드는 실수도 생깁니다. 따라서 "쓰기 SQL은 이 함수로 진입한다"는 규칙을 먼저 만들었습니다.

 

파일을 나눴으므로 lock도 파일별입니다. catalog-a.db를 갱신하는 동안 counters.db의 검색 횟수 증가는 진행될 수 있습니다. 단일 파일의 제약은 지키면서, 관련 없는 쓰기까지 모두 일렬로 세우지는 않는 방식입니다.

데이터 이전은 복사보다 검증이 먼저다

파일 분리는 새로 설치하는 환경보다 이미 운영 중인 환경에서 더 어렵습니다. 기존 주 DB에 있는 테이블을 새 파일로 옮기는 과정에서 원본을 너무 일찍 지우면 복구가 어렵고, 재시작했을 때 같은 데이터를 두 번 넣어도 곤란합니다.

 

카탈로그 테이블 이전은 다음 순서로 동작합니다.

1. 기존 테이블 존재 여부 확인
2. 원본·대상 행 수 확인
3. 원본에만 데이터가 있을 때 복사
4. 복사 후 대상 행 수가 원본과 같은지 검증
5. 검증이 통과했을 때만 원본 테이블 제거
copy_catalog_table(main_pool, catalog_pool).await?;
let copied_count = table_row_count(catalog_pool, LegacyTable::Catalog).await?;
if copied_count != source_count {
    return Err(sqlx::Error::Protocol(format!(
        "catalog 이전 행 수 불일치: 원본={source_count}, 대상={copied_count}"
    )));
}

sqlx::query("DROP TABLE IF EXISTS catalog")
    .execute(main_pool)
    .await?;

대상 DB에 이미 데이터가 있는 상태도 조심해서 다룹니다. 원본과 대상에 모두 데이터가 있다면 어느 쪽이 정답인지 코드가 추측하면 안 됩니다. 카탈로그 이전은 이 경우 오류를 내고 중단합니다. 반면 통계나 활동 분석처럼 과거 배포에서 이미 이전을 끝냈을 수 있는 데이터는 복사를 건너뛰고, 의도를 명시한 로그를 남긴 뒤 남은 원본 테이블만 정리합니다.

 

즉 마이그레이션은 "기존 파일을 새 파일로 복사하는 스크립트"가 아니라, 재시작해도 안전하고 애매한 상태에서는 멈추는 부팅 단계입니다. 애매할 때 자동으로 고치는 것보다 실패하고 원인을 보여 주는 편이 개인 서비스에서도 훨씬 비용이 적습니다.

스키마 마이그레이션과 데이터 이전은 역할이 다르다

새 DB의 스키마 생성에는 sqlx::migrate!를 사용합니다. 각 파일이 자신의 migrations/ 디렉터리를 가지므로 새 환경은 빈 파일에서 필요한 테이블을 만들 수 있습니다.

 

하지만 예전 테이블에 새 컬럼을 넣거나, 파일 간 데이터를 옮기는 일은 별도의 호환성 코드가 맡습니다. 특히 baseline SQL이 CREATE TABLE IF NOT EXISTS라면, 이미 존재하는 오래된 테이블 정의가 자동으로 바뀌지 않습니다. 이 때문에 분석 DB는 부팅 시 새 측정값 컬럼의 존재를 검사하고, 없을 때만 ALTER TABLE을 수행합니다.

 

이 브리지 코드는 영구적인 설계가 아닙니다. 모든 운영 DB가 새 구조로 정착하면 제거할 수 있는 임시 호환 계층입니다. 다만 그 기간에는 "새 설치"와 "오래된 설치의 업그레이드"를 같은 코드에서 안전하게 지원합니다.

언제 파일을 나누고, 언제 나누지 않을까

파일 분리는 만능 해법이 아닙니다. 조인이 자주 필요한 테이블, 하나의 트랜잭션으로 반드시 함께 바뀌어야 하는 데이터, 동일한 요청 흐름에서 항상 같이 읽는 작은 데이터는 같은 DB에 두는 편이 단순합니다. SQLite의 파일 경계를 넘는 일은 쿼리와 백업, 운영 절차를 조금 더 복잡하게 만듭니다.

 

반대로 다음 조건이 겹치면 분리를 검토할 만합니다.

  • 대량 배치가 일반 웹 요청의 쓰기 대기 시간을 늘립니다.
  • 데이터의 보존 주기나 백업·복구 단위가 다릅니다.
  • 한 도메인의 스키마·인덱스 변경이 다른 도메인의 위험을 불필요하게 키웁니다.
  • 파일별 용량 증가와 장애 원인을 따로 관찰하고 싶습니다.

이 프로젝트에서 통계·활동 분석·카탈로그는 이 조건에 맞았고, 그래서 별도 파일이 됐습니다. 반면 별도 DB 서버나 복잡한 분산 구조까지는 필요하지 않았습니다.

마무리

SQLite를 선택했다는 것은 "데이터가 작다"는 선언이 아니라, 운영 복잡도를 어디까지 감수할지 정하는 선택에 가깝습니다. 이 프로젝트에서는 한 파일로 시작해도 됐지만, 데이터의 쓰기 특성이 달라지는 시점에 파일과 writer lock을 분리했습니다.

 

결과적으로 얻은 것은 고가용성 클러스터가 아닙니다. 대신 한 대의 서버, 하나의 프로세스, 익숙한 SQLite 운영 방식은 유지하면서도 배치와 사용자 요청이 서로를 덜 방해하는 구조입니다. 개인 프로젝트에서는 그 정도의 분리가 가장 오래 유지되기 쉽습니다.

728x90
반응형

댓글