[aniflux 1/5] Go + htmx + SQLite로 애니 자동화 서비스 만들기
도입
Sonarr와 Radarr를 꽤 오래 썼습니다.
영미권 미디어 자동화에는 정말 좋은 도구인데, 애니메이션 쪽으로 오면 이야기가 달라집니다.
한국어 자막을 자동으로 붙이는 건 꿈도 못 꾸고, Jackett 연동도 느리고, 설정 화면에서 길을 잃는 일이 잦았습니다.
결국 매주 수동으로 에피소드 검색하고, 자막 블로그 돌아다니고, 파일 이름 바꿔서 Plex 폴더에 넣는 루틴이 반복됐습니다.
그래서 직접 만들기로 했습니다.
이름은 aniflux. 방영 중인 애니를 구독하면 에피소드 다운로드부터 자막 매칭, Plex 정리까지 알아서 하는 서비스입니다.
배경
기술 스택을 고를 때 기준은 명확했습니다.
Go를 고른 건 single binary 때문이었거든요.
소규모 홈서버에서 돌릴 거라 메모리도 중요했는데, Go는 idle 상태에서 20~30MB면 충분합니다.
cross-compile이 쉬운 것도 장점이었습니다.
arm64 NAS에서도 돌릴 수 있으니까요.
프론트엔드는 React나 Vue를 붙일까 잠깐 고민했는데, 혼자 쓰는 서비스에 SPA 프레임워크는 과하다고 판단했습니다.
Go template으로 서버 사이드 렌더링하고, htmx로 인터랙션만 붙이면 됩니다.
빌드 파이프라인도 필요 없고, node_modules도 없습니다.
데이터베이스는 SQLite를 골랐습니다.
단일 유저 서비스에 PostgreSQL 띄우는 건 낭비거든요.
파일 하나가 곧 DB라서 백업도 cp 한 번이면 끝입니다.
과정
main.go의 흐름은 단순합니다.
func main() {
cfg := config.Load()
database := db.Open(cfg.DBPath)
sched := scheduler.New(cfg, database)
handler := web.NewRouter(cfg, database, sched)
go sched.Start(ctx)
http.ListenAndServe(":8080", handler)
}
config를 읽고, DB를 열고, 스케줄러를 시작하고, 웹 서버를 띄웁니다.
의존성 방향은 항상 위에서 아래로 흐릅니다.
패키지 레이아웃은 internal/ 아래에 기능별로 나눴습니다.
internal/source, internal/subtitle, internal/scheduler, internal/web 정도.
DI 컨테이너 같은 건 안 쓰고, 그냥 struct에 필요한 의존성을 필드로 들고 있는 방식입니다.
프레임워크 없이 net/http 표준 라이브러리만 씁니다.
static 파일은 go:embed로 바이너리에 포함시켰습니다.
CSS, JS, htmx 라이브러리 파일 전부 빌드 시점에 들어갑니다.
삽질
embed는 편한데 함정이 있더라고요.
//go:embed static/*으로 디렉토리를 임베드하면 경로에 static/ prefix가 붙어서, http.FileServer에 넘길 때 StripPrefix를 빼먹으면 404가 납니다.
개발 모드에서는 로컬 파일을 읽고 프로덕션에서는 embed를 읽도록 분기하는 것도 은근 귀찮았습니다.
SQLite 쪽은 WAL 모드 설정이 핵심이었습니다.
스케줄러가 백그라운드에서 write하고, 웹 핸들러가 동시에 read하는 구조라 WAL 없이는 database is locked 에러가 터집니다.
db.Exec("PRAGMA journal_mode=WAL")
db.Exec("PRAGMA busy_timeout=5000")
이 두 줄로 대부분 해결됐는데, connection pool 크기도 write 1개, read 여러 개로 분리해야 안정적이었습니다.
mattn/go-sqlite3 대신 modernc.org/sqlite를 쓴 건 CGO 의존성을 없애기 위해서였는데, 빌드 시간이 좀 길어지는 tradeoff가 있습니다.
결과
최종 바이너리 크기는 약 12MB.
Docker 이미지는 scratch 베이스로 15MB 정도입니다.
메모리는 idle 30MB, 에피소드 검색 중에도 50MB를 넘지 않더라고요.
cold start는 1초 미만입니다.
혼자 쓰는 서비스치고는 과하게 만든 감이 있긴 한데, 기술 선택 자체는 만족스럽습니다.
다만 ORM 없이 SQL을 직접 쓰다 보니 마이그레이션 관리가 좀 번거롭긴 합니다.
htmx는 복잡한 인터랙션에서 한계가 있지만, 이 서비스의 WebUI는 설정과 상태 확인용이라 그 이상이 필요하지 않았습니다.
참고
- Go embed 패키지 — static 파일 바이너리 포함
- htmx — HTML 속성만으로 AJAX 처리
- SQLite WAL mode — 동시 읽기/쓰기 해결
- modernc.org/sqlite — CGO 없는 pure Go SQLite 드라이버