Golang으로 SQLite 동시성 처리

강다빈·2026년 7월 15일

golang 공부

목록 보기
8/11

1. SQLite 한계

SQLite는 애플리케이션에 내장되어 직접 파일을 읽고 쓰는 방식으로 동작. 이로 인해 아래와 같은 한계 발생.

  • File Lock 기반 동시성 제어: 기본 저널링 모드(Rollback Journal)에서는 쓰기 작업이 시작되면 파일 전체에 Lock이 걸림
  • Read/Write 상호 블로킹: Lock이 걸린 동안 다른 프로세스나 스레드도 데이터를 읽거나 쓸 수 없음, 읽기와 쓰기 작업을 동시에 수행할 수 없음
  • Go 언어와 충돌: Go에서 기본적으로 Connection Pool을 사용하여 여러 고루틴이 동시에 데이터베이스에 접근하면 Lock이 걸리고 대기 시간을 초과하여 'database is locked'(SQLITE_BUSY) 에러 발생

2. 동시성 처리 방법

  • WAL 모드: 읽기와 쓰기 동시 수행
  • 쓰기 직렬화: 단일 고루틴 사용하여 쓰기와 쓰기 간의 충돌 방지
  • 읽기 병렬화: 커넥션 풀과 여러 개의 고루틴을 사용하여 빠르게 읽기 수행

(1) WAL 모드

WAL 모드란

데이터베이스를 수정할 때 원본 .db 파일을 직접 덮어쓰는 대신, 변경 사항을 .db-wal (Write-Ahead Log) 이라는 별도의 로그 파일 끝에 순차적으로 추가(Append)하는 방식

  • .db-wal 파일: 쓰기 트랜잭션이 기록되는 곳으로 데이터가 이곳에 먼저 쌓인 뒤, 나중에 적절한 시점(Checkpoint)에 원본 .db 파일로 일괄 병합됨
  • .db-shm(Shared Memory) 파일: .db-wal 파일의 인덱스 역할을 하는 공유 메모리 파일로 여러 커넥션이 WAL 파일 내의 최신 데이터를 매우 빠르게 찾을 수 있도록 함. 데이터베이스가 닫히면 자동으로 지워짐

WAL 모드를 사용하면 쓰기와 읽기 작업 간의 동시성을 확보할 수 있음(다른 곳에서 데이터 쓰기 작업을 하고 있어도 다른 곳에서 데이터 읽기 작업을 수행할 수 있음), 또한 무작위 쓰기가 아닌 순차적 쓰기를 수행하므로 디스크 I/O가 최적화 됨.

dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)

sql.Open을 호출할 때 DSN(Data Source Name) 문자열에서 WAL 활성화하여 파라미터로 넘겨줌

  • _pragma=journal_mode(WAL): 앞서 설명한 WAL 모드를 활성화함. 이 설정 덕분에 커넥션이 맺어질 때 .db-wal과 .db-shm 파일이 자동으로 생성됨.

(2) 쓰기 직렬화

쓰기와 읽기 간의 동시성은 해결했지만, SQLite 구조상 단 하나의 쓰기 트랜잭션(최소 작업 단위)만 허용하므로 여전히 쓰기와 쓰기의 충돌은 해결할 수 없음.

이를 해결하기 위해 단일 고루틴으로 단일 쓰기를 보장함.

func NewStore(dbPath string) (*Store, error) {
	// WAL 모드로 SQLite 열기
	dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", dbPath)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(4) // 읽기(Read)는 병렬로 4개까지 허용
	s := &Store{
		db:     db,
		writes: make(chan writeJob, 100), // 최대 100개의 쓰기 작업이 대기할 수 있는 채널 생성
		done:   make(chan struct{}),      // 종료 신호 채널 생성
	}
	// ★ 핵심: 딱 하나뿐인 '쓰기 전담 고루틴(Worker)'을 백그라운드에서 출근시킵니다.
	s.wg.Add(1) 
	go s.writer() 
	return s, nil
}
// 단일 쓰기 고루틴 본체 (오직 얘 혼자서만 DB에 글을 씁니다)
func (s *Store) writer() {
	defer s.wg.Done() // 퇴근할 때 WaitGroup에 "저 일 다 끝내고 갑니다"라고 알림
	for {
		select {
		case job := <-s.writes: 
			// 채널(writes)에 누군가 작업을 던지면, 쏙 꺼내서 순서대로 처리합니다.
			// 오직 하나의 고루틴 안에서 실행되므로 절대 다른 쓰기 작업과 충돌(병렬 실행)하지 않습니다.
			_, err := s.db.ExecContext(job.ctx, job.query, job.args...)
			
			// 처리가 끝났으면, 작업을 부탁한 사람의 result 채널로 결과를 배달해줍니다.
			job.result <- err 
		case <-s.done:
			// "이제 영업 끝났어(Close)"라는 done 신호를 받으면 루프를 탈출합니다.
			// 단, 아직 채널에 줄 서 있는 남은 손님(작업)들이 있다면 마저 처리해 줍니다.
			for len(s.writes) > 0 {
				job := <-s.writes
				_, err := s.db.ExecContext(job.ctx, job.query, job.args...)
				job.result <- err
			}
			return // 완전히 퇴근(루프 종료)
		}
	}
}

(3) 읽기 병렬화

WAL 모드의 가장 큰 장점은 누군가 쓰기 작업을 하고 있어도 읽기 작업이 전혀 차단(Blocking)되지 않는다는 점임.
따라서 쓰기 작업과 달리, 읽기 작업은 여러 개의 고루틴이 동시에 데이터에 접근하도록 설계할 수 있음.

db.SetMaxOpenConns(4)
  • Go 언어database/sql은 내부적으로 커넥션 풀(Connection Pool)을 알아서 관리
  • 위 코드는 "데이터베이스와 연결되는 물리적인 선을 최대 4개까지만 열어두겠다"는 커넥션 풀 설정
  • 이를 통해 수십 개의 고루틴이 동시에 읽기 요청을 보내더라도, 커넥션 풀이 최대 4개의 쿼리를 병렬(Parallel)로 안전하게 분산 처리
  • 5번째 요청부터는 큐에 대기하다가 연결이 반환되면 즉시 재사용하므로, DB에 과도한 부하를 주지 않으면서도 읽기 속도를 극대화할 수 있음

3. 전체 코드

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"sync"

	_ "modernc.org/sqlite" // 순수 Go SQLite 드라이버
)

// 1. 쓰기 작업을 포장하는 상자 (Job 구조체)
type writeJob struct {
	ctx    context.Context
	query  string       // 실행할 쿼리 (예: INSERT INTO ...)
	args   []any        // 쿼리에 들어갈 데이터
	result chan error   // 작업이 끝난 후 결과를 돌려받을 우체통(채널)
}

// 2. 데이터베이스와 단일 고루틴을 관리하는 관리자 (Store 구조체)
type Store struct {
	db     *sql.DB
	writes chan writeJob  // 쓰기 작업들이 줄을 서서 대기할 큐(버퍼 채널)
	done   chan struct{}  // 종료 신호를 알리는 방송용 채널
	wg     sync.WaitGroup // 고루틴이 안전하게 종료될 때까지 기다려주는 역할
}

// 3. Store 초기화 및 고루틴 시작
func NewStore(dbPath string) (*Store, error) {
	// WAL 모드로 SQLite 열기
	dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", dbPath)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(4) // 읽기(Read)는 병렬로 4개까지 허용

	s := &Store{
		db:     db,
		writes: make(chan writeJob, 100), // 최대 100개의 쓰기 작업이 대기할 수 있는 채널 생성
		done:   make(chan struct{}),      // 종료 신호 채널 생성
	}

	// ★ 핵심: 딱 하나뿐인 '쓰기 전담 고루틴(Worker)'을 백그라운드에서 출근시킵니다.
	s.wg.Add(1) 
	go s.writer() 

	return s, nil
}

// 4. 단일 쓰기 고루틴 본체 (오직 얘 혼자서만 DB에 글을 씁니다)
func (s *Store) writer() {
	defer s.wg.Done() // 퇴근할 때 WaitGroup에 "저 일 다 끝내고 갑니다"라고 알림

	for {
		select {
		case job := <-s.writes: 
			// 채널(writes)에 누군가 작업을 던지면, 쏙 꺼내서 순서대로 처리합니다.
			// 오직 하나의 고루틴 안에서 실행되므로 절대 다른 쓰기 작업과 충돌(병렬 실행)하지 않습니다.
			_, err := s.db.ExecContext(job.ctx, job.query, job.args...)
			
			// 처리가 끝났으면, 작업을 부탁한 사람의 result 채널로 결과를 배달해줍니다.
			job.result <- err 

		case <-s.done:
			// "이제 영업 끝났어(Close)"라는 done 신호를 받으면 루프를 탈출합니다.
			// 단, 아직 채널에 줄 서 있는 남은 손님(작업)들이 있다면 마저 처리해 줍니다.
			for len(s.writes) > 0 {
				job := <-s.writes
				_, err := s.db.ExecContext(job.ctx, job.query, job.args...)
				job.result <- err
			}
			return // 완전히 퇴근(루프 종료)
		}
	}
}

// 5. 외부에서 쓰기 작업을 요청하는 함수 (직접 쓰지 않고 채널에 던짐)
func (s *Store) WriteData(ctx context.Context, query string, args ...any) error {
	// 1회용 결과 반환 채널을 만듭니다. (버퍼를 1로 두어 블로킹 방지)
	resultCh := make(chan error, 1)

	// 작업을 상자에 예쁘게 포장해서 writes 채널(컨베이어 벨트)에 올려둡니다.
	s.writes <- writeJob{
		ctx:    ctx,
		query:  query,
		args:   args,
		result: resultCh,
	}

	// 백그라운드에 있는 writer 고루틴이 내 작업을 꺼내서 처리해줄 때까지 기다렸다가, 결과를 받습니다.
	return <-resultCh
}

// 6. 안전한 종료 처리 (Graceful Shutdown)
func (s *Store) Close() {
	close(s.done) // 1. writer 고루틴에게 "이제 더 이상 작업 안 받아, 퇴근 준비해"라고 방송함
	s.wg.Wait()   // 2. writer 고루틴이 남은 일을 다 마치고 완전히 꺼질 때까지 대기
	s.db.Close()  // 3. 최종적으로 DB 연결 종료
}

// --- 실행 테스트 ---
func main() {
	store, _ := NewStore("test.db")
	defer store.Close()

	var wg sync.WaitGroup

	// 10명의 사람(고루틴)이 동시에 DB에 글을 쓰려고 달려드는 상황 가정
	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			
			// 이 코드는 10번 동시에 실행되지만, 
			// 내부는 채널에 줄을 서게 되므로 DB 입장에서는 완벽히 순서대로 처리됩니다.
			err := store.WriteData(context.Background(), "INSERT INTO users (name) VALUES (?)", fmt.Sprintf("User%d", id))
			if err != nil {
				log.Println("에러:", err)
			}
		}(i)
	}

	wg.Wait() // 10명이 쓰기 요청을 다 마칠 때까지 대기
	fmt.Println("모든 쓰기 작업 완료! 락(Lock) 충돌은 전혀 없었습니다.")
}
profile
하루살이

0개의 댓글