[번역] 단일 프로세스 프로그래밍 노트 (One process programming notes)

Go와 SQLite를 활용한 1인 개발자의 단일 프로세스 아키텍처 구축기

Woojong Koh

Important

This is an unofficial Korean translation of One process programming notes (with Go and SQLite) by David Crawshaw.

원문 게시일: 2018년 7월 30일

이 글은 제가 Go Northwest에서 발표한 내용의 블로그 버전입니다.

이 글은 제가 인디 개발자로서 인터넷 서비스, iOS 앱, macOS 프로그램을 작성하며 최근 탐구한 내용을 다룹니다.

여기에는 각각 별도의 블로그 게시물로 다루어야 할 여러 주제가 있습니다. 하지만 당장 프로그래밍할 것이 많기 때문에 일단 이 노트들을 그대로 올리고 나중에 내용을 분리할 생각입니다.

제 초점은 구글에서 팀으로 일하며 배운 교훈을 1인 프로그래머가 소규모 비즈니스 작업을 구축하는 환경에 어떻게 적용할 것인가에 맞춰져 있습니다. 실리콘 밸리의 대기업과 자본력이 풍부한 VC 기업들에는 훌륭한 엔지니어링 관행이 많지만, 한 사람이 그 모든 것을 사용하며 소프트웨어까지 작성할 여력은 없습니다. 저에게 주어진 과제는 ‘무엇을 남기고 무엇을 버릴 것인가’입니다.

제가 제대로 해왔다면, 여기에 설명된 기술과 기법들은 쉬워 보일 것입니다. 사람들이 원하는 소프트웨어를 작성할 수 있는 충분한 여력을 남겨두면서도, 이 모든 것을 머릿속에 담아둘 수 있어야 합니다. 모든 추가적인 것에는 큰 비용이 따르며, 특히 거의 건드리지 않다가 6개월 뒤 한밤중에 문제를 일으키는 소프트웨어는 더욱 그렇습니다.

제가 사용하기로 결정한 두 가지 핵심 기술은 Go와 SQLite입니다.

SQLite 간단 소개 #

SQLite는 SQL의 구현체입니다. PostgreSQL이나 MySQL과 같은 전통적인 데이터베이스 구현체와 달리, SQLite는 프로그램에 내장되도록 설계된 독립적인 C 라이브러리입니다. 2000년 출시 이후 D. Richard Hipp에 의해 구축되었으며, 지난 18년 동안 다른 오픈 소스 기여자들도 도움을 주었습니다. 현시점에서 제가 프로그래밍을 해온 대부분의 시간 동안 존재해왔으며, 제 프로그래밍 도구 상자의 핵심 부분입니다.

SQLite 명령줄 도구 실습 #

추상적으로 SQLite에 대해 이야기하기보다는 직접 보여드리겠습니다.

Kaggle의 친절한 누군가가 셰익스피어 희곡의 CSV 파일을 제공했습니다. 이를 이용해 SQLite 데이터베이스를 만들어 보겠습니다.

$ head shakespeare_data.csv
"Dataline","Play","PlayerLinenumber","ActSceneLine","Player","PlayerLine"
"1","Henry IV",,,,"ACT I"
"2","Henry IV",,,,"SCENE I. London. The palace."
"3","Henry IV",,,,"Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR WALTER BLUNT, and others"
"4","Henry IV","1","1.1.1","KING HENRY IV","So shaken as we are, so wan with care,"
"5","Henry IV","1","1.1.2","KING HENRY IV","Find we a time for frighted peace to pant,"
"6","Henry IV","1","1.1.3","KING HENRY IV","And breathe short-winded accents of new broils"
"7","Henry IV","1","1.1.4","KING HENRY IV","To be commenced in strands afar remote."
"8","Henry IV","1","1.1.5","KING HENRY IV","No more the thirsty entrance of this soil"
"9","Henry IV","1","1.1.6","KING HENRY IV","Shall daub her lips with her own children's blood,"

먼저, sqlite 명령줄 도구를 사용하여 새로운 데이터베이스를 만들고 CSV를 가져와 보겠습니다.

$ sqlite3 shakespeare.db
sqlite> .mode csv
sqlite> .import shakespeare_data.csv import

완료되었습니다! 몇 개의 SELECT 문을 통해 제대로 작동했는지 빠르게 확인할 수 있습니다.

sqlite> SELECT count(*) FROM import;
111396
sqlite> SELECT * FROM import LIMIT 10;
1,"Henry IV","","","","ACT I"
2,"Henry IV","","","","SCENE I. London. The palace."
3,"Henry IV","","","","Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR WALTER BLUNT, and others"
4,"Henry IV",1,1.1.1,"KING HENRY IV","So shaken as we are, so wan with care,"
5,"Henry IV",1,1.1.2,"KING HENRY IV","Find we a time for frighted peace to pant,"
6,"Henry IV",1,1.1.3,"KING HENRY IV","And breathe short-winded accents of new broils"
7,"Henry IV",1,1.1.4,"KING HENRY IV","To be commenced in strands afar remote."
8,"Henry IV",1,1.1.5,"KING HENRY IV","No more the thirsty entrance of this soil"
9,"Henry IV",1,1.1.6,"KING HENRY IV","Shall daub her lips with her own children's blood,"

좋아 보이네요! 이제 약간의 정리를 해보겠습니다. 원본 CSV에는 막(Act) 번호, 장(Scene) 번호, 대사(Line) 번호를 점(.)으로 인코딩한 ActSceneLine이라는 열이 있습니다. 이것들을 각각의 열로 나누면 훨씬 보기 좋을 것입니다.

sqlite> CREATE TABLE plays (rowid INTEGER PRIMARY KEY, play, linenumber, act, scene, line, player, text);
sqlite> .schema
CREATE TABLE import (rowid primary key, play, playerlinenumber, actsceneline, player, playerline);
CREATE TABLE plays (rowid primary key, play, linenumber, act, scene, line, player, text);
sqlite> INSERT INTO plays SELECT
	row AS rowid,
	play,
	playerlinenumber AS linenumber,
	substr(actsceneline, 1, 1) AS act,
	substr(actsceneline, 3, 1) AS scene,
	substr(actsceneline, 5, 5) AS line,
	player,
	playerline AS text
	FROM import;

(위의 substrinstr을 사용하여 ‘.’ 문자를 찾는 방식으로 개선할 수 있습니다. 이는 독자를 위한 연습 문제로 남겨두겠습니다.)

여기서는 다른 테이블에서 테이블을 만들기 위해 INSERT ... SELECT 구문을 사용했습니다. ActSceneLine 열은 문자열을 자르는 내장 SQLite 함수인 substr을 사용하여 분할되었습니다.

결과:

sqlite> SELECT * FROM plays LIMIT 10;
1,"Henry IV","","","","","","ACT I"
2,"Henry IV","","","","","","SCENE I. London. The palace."
3,"Henry IV","","","","","","Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR WALTER BLUNT, and others"
4,"Henry IV",1,1,1,1,"KING HENRY IV","So shaken as we are, so wan with care,"
5,"Henry IV",1,1,1,2,"KING HENRY IV","Find we a time for frighted peace to pant,"
6,"Henry IV",1,1,1,3,"KING HENRY IV","And breathe short-winded accents of new broils"
7,"Henry IV",1,1,1,4,"KING HENRY IV","To be commenced in strands afar remote."
8,"Henry IV",1,1,1,5,"KING HENRY IV","No more the thirsty entrance of this soil"
9,"Henry IV",1,1,1,6,"KING HENRY IV","Shall daub her lips with her own children's blood,"

이제 데이터가 생겼으니 무언가를 검색해 보겠습니다.

sqlite> SELECT * FROM plays WHERE text LIKE "whether tis nobler%";
sqlite>

작동하지 않았네요. 햄릿이 분명히 저 말을 했지만, 아마 텍스트 형식이 약간 다를 것입니다. 이럴 때 SQLite가 도움이 됩니다. SQLite에는 전체 텍스트 검색(Full Text Search) 확장이 컴파일되어 포함되어 있습니다. FTS5를 사용하여 셰익스피어의 모든 텍스트를 색인해 봅시다:

sqlite> CREATE VIRTUAL TABLE playsearch USING fts5(playsrowid, text);
sqlite> INSERT INTO playsearch SELECT rowid, text FROM plays;

이제 우리의 독백을 검색할 수 있습니다.

sqlite> SELECT rowid, text FROM playsearch WHERE text MATCH "whether tis nobler";
34232|Whether 'tis nobler in the mind to suffer

성공입니다! 막과 장은 원본 테이블과 조인하여 얻을 수 있습니다.

sqlite> SELECT play, act, scene, line, player, plays.text
	FROM playsearch
	INNER JOIN plays ON playsearch.playsrowid = plays.rowid
	WHERE playsearch.text MATCH "whether tis nobler";
Hamlet|3|1|65|HAMLET|Whether 'tis nobler in the mind to suffer

정리해 봅시다.

sqlite> DROP TABLE import;
sqlite> VACUUM;

마지막으로, 이 모든 것이 파일 시스템에서는 어떻게 보일까요?

$ ls -l
-rwxr-xr-x@ 1 crawshaw  staff  10188854 Apr 27  2017 shakespeare_data.csv
-rw-r--r--  1 crawshaw  staff  22286336 Jul 25 22:05 shakespeare.db

보시다시피, SQLite 데이터베이스는 셰익스피어 희곡의 전체 복사본 두 개(그 중 하나는 전체 텍스트 검색 색인)를 포함하고 있으며, 이 두 개를 모두 저장하는 데 원본 CSV 파일 하나를 저장하는 데 필요한 공간의 약 두 배만을 차지합니다. 나쁘지 않네요.

이것으로 SQLite의 가벼움(lite)과 특징을 어느 정도 체감하셨을 것입니다.

그리고, 컷(막을 내립니다).

Go에서 SQLite 사용하기 #

표준 database/sql #

SQLite를 위해 사용할 수 있는 cgo 기반의 database/sql 드라이버가 많이 있습니다. 가장 인기 있는 것은 github.com/mattn/go-sqlite3인 것 같습니다. 제 역할을 잘 수행하며 아마 여러분이 원하는 드라이버일 것입니다.

database/sql 패키지를 사용하면 SQLite 데이터베이스를 열고 그 위에서 SQL 문을 실행하는 것이 간단합니다. 예를 들어, 다음 Go 코드를 사용하여 앞서 본 FTS 쿼리를 실행할 수 있습니다:

package main

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

	_ "github.com/mattn/go-sqlite3"
)

func main() {
	db, err := sql.Open("sqlite3", "shakespeare.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	stmt, err := db.Prepare(`
		SELECT play, act, scene, plays.text
		FROM playsearch
		INNER JOIN plays ON playsearch.playrowid = plays.rowid
		WHERE playsearch.text MATCH ?;`)
	if err != nil {
		log.Fatal(err)
	}
	var play, text string
	var act, scene int
	err = stmt.QueryRow("whether tis nobler").Scan(&play, &act, &scene, &text)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s %d:%d: %q
", play, act, scene, text)
}

이를 실행하면 다음과 같은 결과가 나옵니다:

Hamlet 3:1 "Whether 'tis nobler in the mind to suffer"

로우레벨 래퍼: crawshaw.io/sqlite #

SQLite가 전체 텍스트 검색을 통해 SELECT, INSERT, UPDATE, DELETE의 기본을 넘어선 것처럼, SQL 문만으로는 접근할 수 없는 여러 흥미로운 기능과 확장을 가지고 있습니다. 이러한 기능들은 특수한 인터페이스를 필요로 하며, 기존 드라이버 중 어느 것도 많은 인터페이스를 지원하지 않습니다.

그래서 저는 저만의 드라이버를 작성했습니다. crawshaw.io/sqlite에서 받을 수 있습니다. 특히 스트리밍 블롭(blob) 인터페이스, 세션 확장을 지원하며, 커넥션 풀을 위한 공유 캐시(shared cache)를 잘 활용하는 데 필요한 sqlite_unlock_notify 메커니즘을 구현합니다. 저는 클라이언트와 클라우드라는 두 가지 사용 사례 연구를 통해 이러한 기능들을 다뤄보겠습니다.

cgo #

이러한 접근 방식은 모두 C를 Go에 통합하기 위해 cgo에 의존합니다. 이는 간단하게 할 수 있지만, 약간의 운영상 복잡성을 더합니다. SQLite를 사용하는 Go 프로그램을 빌드하려면 대상 환경에 맞는 C 컴파일러가 필요합니다.

실제로는 macOS에서 개발하는 경우 리눅스용 크로스 컴파일러를 설치해야 한다는 것을 의미합니다.

Go에 C 코드를 추가할 때 소프트웨어 품질에 미칠 영향에 대한 일반적인 우려는 SQLite에는 적용되지 않습니다. SQLite는 엄청난 수준의 테스트를 거치기 때문입니다. 코드의 품질이 예외적으로 우수합니다.

클라이언트를 위한 Go와 SQLite #

저는 대부분의 코드를 Go로 작성하고 UI는 웹 뷰로 제공하는 iOS 앱을 만들고 있습니다. 이 앱은 인터넷 서버를 얇게 비추는 뷰가 아니라 사용자 데이터의 전체 복사본을 가지고 있습니다. 이는 대량의 구조화된 로컬 데이터를 저장하고, 기기 내 전체 텍스트 검색을 지원하며, UI를 방해하지 않는 방식으로 데이터베이스에서 작동하는 백그라운드 작업을 수행하고, DB 변경 사항을 클라우드의 백업에 동기화해야 함을 의미합니다.

클라이언트치고는 움직이는 부품이 아주 많습니다. 제가 JavaScript로 작성하고 싶은 양보다 많고, Swift로 작성했다가 나중에 Android 앱을 만들게 될 경우 즉시 다시 작성해야 하는 양보다도 많습니다. 더 중요한 것은 서버가 Go로 작성되어 있고, 저는 1인 인디 개발자라는 점입니다. 제 개발 환경에서 움직이는 부품의 수를 가능한 최소한으로 줄이는 것이 절대적으로 중요합니다. 그래서 제 서버와 정확히 같은 기술을 사용하여 클라이언트의 핵심적인 부분들을 구축하려고 노력하는 것입니다.

세션 확장 #

세션 확장을 사용하면 SQLite 연결에서 세션을 시작할 수 있습니다. 해당 연결을 통해 데이터베이스에 이루어진 모든 변경 사항은 패치셋(patchset) 블롭으로 묶입니다. 또한 이 확장은 생성된 패치셋을 테이블에 적용하는 방법도 제공합니다.

func (conn *Conn) CreateSession(db string) (*Session, error)

func (s *Session) Changeset(w io.Writer) error

func (conn *Conn) ChangesetApply(
	r          io.Reader,
	filterFn   func(tableName string) bool,
	conflictFn func(ConflictType, ChangesetIter) ConflictAction,
) error

이를 통해 아주 간단한 클라이언트 동기화 시스템을 구축할 수 있습니다. 클라이언트에서 이루어진 변경 사항을 수집하고, 주기적으로 변경셋(changeset)으로 묶어 서버에 업로드한 다음 서버의 데이터베이스 백업 복사본에 적용합니다. 만약 다른 클라이언트가 데이터베이스를 변경하면, 서버는 이를 클라이언트에 알리고 클라이언트는 변경셋을 다운로드하여 적용합니다.

이것은 데이터베이스 설계에 약간의 주의를 요구합니다. 제가 셰익스피어 예제에서 FTS 테이블을 분리해 둔 이유는 FTS 테이블을 분리된 첨부 데이터베이스(SQLite에서는 다른 파일임을 의미함)에 보관하기 때문입니다. 클라우드 백업 데이터베이스는 FTS 테이블을 생성하지 않으며, 클라이언트는 백그라운드 스레드에서 자유롭게 FTS 테이블을 생성할 수 있고 이는 데이터 백업보다 늦어질 수 있습니다.

주의해야 할 또 다른 점은 충돌을 최소화하는 것입니다. 가장 큰 문제는 AUTOINCREMENT 키입니다. 기본적으로 rowid 테이블의 기본 키는 증가하므로, 여러 클라이언트가 rowid를 생성하면 많은 충돌이 발생하게 됩니다.

저는 두 가지 다른 해결책을 시험해보고 있습니다. 첫 번째는 각 클라이언트가 서버에 rowid 범위를 등록하고 자신의 범위 내에서만 할당하도록 하는 것입니다. 이 방법은 잘 작동합니다. 두 번째는 무작위로 int64 값을 생성하고 낮은 충돌률에 의존하는 것입니다. 지금까지는 이 방법도 잘 작동합니다. 두 전략 모두 위험성이 있으며, 저는 아직 어느 것이 더 나은지 결정하지 못했습니다.

실제로 해보니, 변경셋의 품질을 높게 유지하려면 DB 업데이트를 단일 연결로 제한해야 한다는 것을 알게 되었습니다. (변경셋은 다른 연결에서 이루어진 변경 사항을 볼 수 없습니다.) 이를 위해 읽기 전용 커넥션 풀과 1개의 풀에 단일 보호된 읽기-쓰기 연결을 유지합니다. 코드는 필요할 때만 읽기-쓰기 연결을 가져오며, 읽기 전용 연결은 SQLite 연결의 읽기 전용 비트에 의해 강제됩니다.

중첩 트랜잭션 (Nested Transactions) #

database/sql 드라이버는 Tx 타입을 사용하여 SQL 트랜잭션을 사용할 것을 권장하지만, 이는 중첩 트랜잭션과는 잘 맞지 않는 것 같습니다. 중첩 트랜잭션은 SQL에서 SAVEPOINT / RELEASE로 구현되는 개념으로, 이를 통해 놀랍도록 구성 가능한(composable) 코드를 작성할 수 있습니다.

만약 함수가 트랜잭션 내에서 여러 문을 실행해야 한다면, SAVEPOINT로 열고 함수가 Go 반환 오류를 생성하지 않으면 RELEASE 호출을 지연(defer)시키거나, 오류를 생성한다면 대신 ROLLBACK을 호출하고 오류를 반환할 수 있습니다.

func f(conn *sqlite.Conn) (err error) {
	conn...SAVEPOINT
	defer func() {
		if err == nil {
			conn...RELEASE
		} else {
			conn...ROLLBACK
		}
	}()
}

이제 이 트랜잭션 함수 f가 또 다른 트랜잭션 함수 g를 호출해야 한다면, g도 정확히 같은 전략을 사용할 수 있으며 f는 매우 전통적인 Go 방식으로 이를 호출할 수 있습니다:

if err := g(conn); err != nil {
	return err // all changes in f will be rolled back by the defer
}

함수 g 자체도 자신만의 트랜잭션을 가지고 있기 때문에 독자적으로 사용해도 완벽하게 안전합니다.

저는 몇 달째 이 SAVEPOINT + defer RELEASE 또는 에러 반환 의미론을 사용하고 있는데, 정말 귀중한 방법이라는 것을 알게 되었습니다. 이를 통해 코드를 SQL 트랜잭션으로 안전하게 감싸는 것이 쉬워집니다.

하지만 위의 예제는 코드가 약간 방대하고 처리해야 할 엣지 케이스들이 있습니다. (예를 들어, RELEASE가 실패하면 오류를 반환해야 합니다.) 그래서 저는 이것을 다음과 같은 유틸리티로 감쌌습니다:

func f(conn *sqlite.Conn) (err error) {
	defer sqlitex.Save(conn)(&err)

	// Code is transactional and can be stacked
	// with other functions that call sqlitex.Save.
}

처음 sqlitex.Save가 동작하는 것을 보면 조금 어색할 수 있습니다. 적어도 제가 처음 만들었을 때는 그랬습니다. 하지만 금방 익숙해졌고, 이 함수가 많은 어려운 작업을 대신 해줍니다. sqlitex.Save를 처음 호출하면 connSAVEPOINT를 열고, err의 값에 따라 RELEASE 또는 ROLLBACK을 수행하고 필요한 경우 err를 설정하는 클로저를 반환합니다.

클라우드에서의 Go와 SQLite #

저는 지난 몇 달 동안 이전에 접했던 서비스들을 재설계하고 앞으로 작업하고 싶은 문제들을 위한 서비스를 설계해 왔습니다. 이 과정을 통해 많은 문제에 적용할 수 있고 제가 꽤 즐겁게 구축할 수 있는 일반적인 디자인에 도달하게 되었습니다.

이것은 1개의 VM, 1개의 존, **1개의 프로세스 프로그래밍(단일 프로세스 프로그래밍)**으로 요약할 수 있습니다.

이것이 터무니없이 단순하게 들린다면, 아주 좋습니다! 단순하니까요. 이 설계는 우리가 멋진 현대 클라우드 서비스가 충족하기를 바라는 모든 종류의 요구 사항을 충족하지는 않습니다. ‘서버리스(serverless)‘가 아니므로 서비스가 매우 작을 때 무료로 실행되지 않으며, 서비스가 성장할 때 자동으로 확장되지도 않습니다. 실제로 여기에는 명시적인 확장 제한이 있습니다. 현재 Amazon에서 얻을 수 있는 최고의 서버는 대략 다음과 같습니다:

  • 128 CPU 스레드 (~4GHz)
  • 4TB RAM
  • 25 Gbit 이더넷
  • 10 Gbps NAS
  • 연간 수 시간의 다운타임

이것은 단일 프로세스 프로그래밍의 엄청난 잠재적 단점입니다. 하지만 저는 이것이 견딜 만한 한계라고 주장합니다.

저는 일반적인 서비스들이 이러한 확장 한계에 도달하지 않는다고 주장합니다.

소규모 비즈니스를 구축하는 경우, 대부분의 제품은 수년 동안 이 한계치 아래에서도 충분히 성장하고 수익을 낼 수 있습니다. 향후 1~2년 안에 이 한계에 도달하는 것이 보인다면, 귀하는 2명 이상의 엔지니어를 고용할 수 있는 수익을 갖춘 비즈니스를 보유하고 있는 것이며, 새로운 팀은 급격하게 변화하는 비즈니스 요구사항에 맞춰 서비스를 다시 작성할 수 있습니다.

이 한계에 도달하는 것은 행복한 고민입니다. 왜냐하면 그 시기가 오면 그것을 처리할 충분한 시간과 그것을 잘 해결하는 데 필요한 인적 자원을 갖게 될 것이기 때문입니다.

소규모 비즈니스의 초기에는 그렇지 않습니다. 이 확장 한계를 넘어서기 위해 작업하는 데 쓰는 모든 시간은 고객과 그들의 요구에 대해 대화하는 데 더 잘 쓰일 수 있었던 시간입니다.

여기서 작용하는 원칙은 다음과 같습니다:

1대의 컴퓨터면 충분할 때 N대의 컴퓨터를 사용하지 마라.

조금 더 기술적인 세부 사항으로 들어가 보겠습니다.

저는 AWS의 단일 가용 영역에서 단일 VM을 실행합니다. 이 VM에는 세 개의 EBS 볼륨(Amazon의 NAS 이름)이 있습니다. 첫 번째 볼륨은 OS, 로그, 임시 파일 및 메인 데이터베이스에서 생성된 임시 SQLite 데이터베이스(예: FTS 테이블)를 보관합니다. 두 번째는 메인 서비스를 위한 기본 SQLite 데이터베이스를 보관합니다. 세 번째는 고객 동기화 SQLite 데이터베이스를 보관합니다.

이 시스템은 시스템 EBS 볼륨과 고객 EBS 볼륨을 Amazon의 지리적 중복 블롭 스토어인 S3에 주기적으로 스냅샷하도록 구성되어 있습니다. 변경되는 블록만 복사되기 때문에 스크립트로 처리할 수 있는 비교적 저렴한 작업입니다.

메인 EBS 볼륨은 WAL 캐시를 플러시하는 사용자 정의 코드에 의해 S3에 매우 정기적으로 백업됩니다. 이에 대해서는 잠시 후에 설명하겠습니다.

서비스는 이 VM에서 실행되는 단일 Go 바이너리입니다. 기기에는 리눅스의 디스크 캐시로 사용될 수 있는 넉넉한 여분의 RAM이 있습니다. (그리고 이것은 다운타임을 줄이기 위해 두 번째 서비스 복사본을 교체용으로 스핀업하는 데 사용될 수 있습니다.)

이 설계의 결과는 1년에 기껏해야 수십 시간의 다운타임을 가지며, 물리적 컴퓨터의 RAID5 배열만큼이나 블록 손실을 겪을 가능성이 적고 대규모 팀이 구축하고 유지 관리하는 분산 시스템에 몇 분마다 활성 오프사이트 백업이 수행되는 서비스입니다.

이 시스템은 놀라울 정도로 단순합니다. 저는 한 대의 기계에만 쉘(shell)로 접속합니다. 리눅스 머신입니다. 제게는 길이가 열 줄인 서비스 배포 스크립트가 있습니다. 거의 모든 성능 최적화 작업은 pprof로 수행됩니다.

중간 크기의 VM에서는 단 몇 시간의 성능 튜닝만으로 5~6천 개의 동시 요청을 처리할 수 있습니다. AWS가 제공하는 가장 큰 기기에서는 수만 개를 처리할 수 있습니다.

이제 이 기술 스택의 세부 사항에 대해 조금 더 이야기해 보겠습니다:

공유 캐시(Shared cache)와 WAL #

서버를 극도로 동시적으로 만들기 위해 제가 사용하는 두 가지 중요한 SQLite 기능이 있습니다. 첫 번째는 공유 캐시(shared cache)로, 이를 통해 데이터베이스 페이지 캐시에 하나의 큰 메모리 풀을 할당하고 많은 동시 연결이 이를 동시에 사용할 수 있게 합니다. 사용자 코드가 락 이벤트(locking events)를 처리할 필요가 없도록 드라이버에 sqlite_unlock_notify에 대한 약간의 지원이 필요하지만, 이는 최종 사용자 코드에는 투명하게 처리됩니다.

두 번째는 기록 선행 로그(Write Ahead Log, WAL)입니다. 이는 연결을 시작할 때 SQLite를 이 모드로 전환하여 디스크에 트랜잭션을 쓰는 방식을 변경하는 것입니다. 데이터베이스를 잠그고 롤백 저널과 함께 수정 사항을 적용하는 대신, 새로운 변경 사항을 별도의 파일에 추가합니다. 이렇게 하면 읽기 작업이 쓰기 작업과 동시에 작동할 수 있습니다. WAL은 SQLite에 의해 주기적으로 플러시되어야 하며, 여기에는 데이터베이스를 잠그고 거기서 변경 사항을 쓰는 작업이 포함됩니다. 이를 수행하기 위한 기본 설정이 있습니다.

저는 이것들을 재정의(override)하여 완료 시 S3 스냅샷도 트리거하는 패키지에서 수동으로 WAL 플러시를 실행합니다. 이 패키지는 reallyfsync라고 불리며, 제대로 테스트하는 방법을 알아내면 오픈소스로 공개할 예정입니다.

증분형(Incremental) Blob API #

제 특정 서버에서 더 작지만 중요한 또 다른 기능은 SQLite의 증분형 blob API입니다. 이를 통해 모든 바이트를 메모리에 동시에 저장하지 않고도 DB에서 바이트 필드를 읽고 쓸 수 있습니다. 이는 각 요청이 수백 메가바이트로 작동할 수 있지만 수만 개의 잠재적인 동시 요청을 처리하고 싶을 때 중요합니다.

이것은 드라이버가 cgo에 가까운 래퍼에서 벗어나 보다 Go다운(Go-like) 형태로 변경되는 부분 중 하나입니다:

type Blob
    func (blob *Blob) Close() error
    func (blob *Blob) Read(p []byte) (n int, err error)
    func (blob *Blob) ReadAt(p []byte, off int64) (n int, err error)
    func (blob *Blob) Seek(offset int64, whence int) (int64, error)
    func (blob *Blob) Size() int64
    func (blob *Blob) Write(p []byte) (n int, err error)
    func (blob *Blob) WriteAt(p []byte, off int64) (n int, err error)

이것은 파일과 많이 닮아 있으며, 실제로 파일처럼 사용할 수 있습니다. 단 한 가지 주의할 점은, 블롭의 크기는 생성될 때 설정된다는 것입니다. (따라서 저는 여전히 임시 파일이 유용하다고 생각합니다.)

단일 프로세스 프로그래밍으로 설계하기 #

저는 이 질문에서 시작합니다: 정말로 N대의 컴퓨터가 필요한가요?

어떤 문제들은 정말로 그렇습니다. 예를 들어, 4TB의 RAM만으로는 퍼블릭 인터넷의 저지연 인덱스를 구축할 수 없습니다. 훨씬 더 많은 것이 필요합니다. 이런 문제들은 정말 재미있고 우리는 이런 주제로 이야기하는 것을 좋아하지만, 전체 작성된 코드에서 보면 비교적 적은 양을 차지합니다. 구글 퇴사 후 제가 개발하고 있는 모든 프로젝트는 1대의 컴퓨터에 들어맞습니다.

1대의 컴퓨터로 해결하기 어려운 더 흔한 하위 문제들도 있습니다. 글로벌 고객 기반을 보유하고 있고 서버로의 저지연 접속이 필요하다면, 빛의 속도가 걸림돌이 됩니다. 하지만 이러한 문제들 중 상당수는 비교적 간단한 CDN 제품으로 해결할 수 있습니다.

빛의 속도 문제를 해결하는 또 다른 훌륭한 방법은 지역 샤딩(geo-sharding)입니다. 여러 데이터 센터에 서비스의 완전하고 독립적인 복사본을 두고 사용자 데이터를 그들과 가까운 서비스로 이동시킵니다. 이것은 사용자를 {us-east, us-west}.mservice.com과 같은 특정 DNS 이름으로 리디렉션하는 작고 글로벌한 리디렉션 데이터베이스(아마도 지리적 이중화 NFS 상의 SQLite!)를 갖추는 것만큼이나 쉬울 수 있습니다.

대부분의 문제는 어느 시점까지는 1대의 컴퓨터에 적합합니다. 그 시점이 어디인지 파악하는 데 시간을 투자하세요. 만약 그 시점이 수년 후라면, 한 대의 컴퓨터로 충분할 가능성이 높습니다.

기업 프로그래머를 위한 인디 개발 기법 #

여러분이 이 특정 기술 스택으로 코드를 작성하지 않거나 인디 개발자가 아니더라도, 여기에 가치가 있습니다. 거대한 VM 1대, 존 1개, 단일 프로세스 Go, SQLite 및 스냅샷 백업 스택을 당신의 설계를 테스트할 수 있는 가설적 도구로 활용해 보세요.

따라서 설계 프로세스에 가상적인 단계를 추가해 보세요: 만약 한 대의 컴퓨터를 사용하여 이 스택 위에서 문제를 해결한다면, 어디까지 갈 수 있을까요? 얼마나 많은 고객을 지원할 수 있을까요? 어느 정도 규모가 되면 소프트웨어를 다시 작성해야 할까요?

만약 이 인디용 미니 스택이 수년 동안 비즈니스를 지탱할 수 있다면, 현대적인 클라우드 소프트웨어 도입을 미루는 것을 고려해 볼 수 있습니다.

여러분이 자본력이 풍부한 기업의 프로그래머라면, 소규모 내부 프로젝트나 실험적 프로젝트를 위한 개발 환경이 어때야 하는지 고민해 볼 수 있습니다. 동료들이 정책적인 이유로 대규모의 복잡한 분산 시스템을 사용해야만 합니까? 이러한 프로젝트 중 다수는 1대의 컴퓨터를 넘어서 확장할 필요가 결코 없으며, 만약 확장해야 한다면 변화하는 요구사항에 대처하기 위해 다시 작성해야 할 것입니다. 이런 경우에는 프로토타이핑과 실험을 위해 파일 시스템이 있는 리눅스 VM과 같은 인디 스택을 사용할 수 있는 방법을 찾아보세요.