SQLite는 애플리케이션에 내장되어 직접 파일을 읽고 쓰는 방식으로 동작. 이로 인해 아래와 같은 한계 발생.
데이터베이스를 수정할 때 원본 .db 파일을 직접 덮어쓰는 대신, 변경 사항을 .db-wal (Write-Ahead Log) 이라는 별도의 로그 파일 끝에 순차적으로 추가(Append)하는 방식
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 활성화하여 파라미터로 넘겨줌
쓰기와 읽기 간의 동시성은 해결했지만, 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 // 완전히 퇴근(루프 종료)
}
}
}
WAL 모드의 가장 큰 장점은 누군가 쓰기 작업을 하고 있어도 읽기 작업이 전혀 차단(Blocking)되지 않는다는 점임.
따라서 쓰기 작업과 달리, 읽기 작업은 여러 개의 고루틴이 동시에 데이터에 접근하도록 설계할 수 있음.
db.SetMaxOpenConns(4)
database/sql은 내부적으로 커넥션 풀(Connection Pool)을 알아서 관리 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) 충돌은 전혀 없었습니다.")
}