dogfoods: 6번 DB Pool — 두 번째 데이터 점, 그리고 resource/with 결정

파일에 없던 셋을 압박했다: 자원 두 층(풀/lease), 반납이 파괴가 아님, 획득이
실패할 수 있음. 셋 다 설계가 버텼다.

D8 — 인자 안에서 소비와 사용을 섞을 수 없다.
release_then(db, pool, l, db.query(l, sql))가 거부된다. move 검사가 인자를
왼쪽부터 걷기 때문이고, 실제 평가 순서와 무관하다. 우회는 쉽지만 왜 안 되는지가
코드에서 안 보인다.

두 사례 비교:
                naive  careful  배수  match 중첩
  Atomic          18      58    3.2    4단
  DB Pool         25      58    2.3    1단

공통(2/2): 자원을 들면 ?를 한 번도 못 쓴다. "정리하고 결과를 실어나르는
도우미"를 양쪽이 각자 발명했다. 누수는 조용히 통과한다.
다른 점: 고통이 자원 개수가 아니라 정리의 균일성에 비례한다. 파일은 단계마다
정리가 달라 4단이 되고, 풀은 언제나 반납이라 1단으로 접힌다.

결정: resource/with를 넣는다. 다만 D4가 먼저다.
with l = db.acquire(pool)? { } 가 나갈 때 무엇을 부를지 알려면 자원만으로
정리를 표현할 수 있어야 하는데, 반납은 db.release(pool, l)이라 풀과 권한이
필요하다. 자원이 자기 정리를 스스로 선언하려면 수신자를 소비하는 메서드가
있어야 하고(own self), 지금은 own이 파라미터에만 붙는다.
파일 쪽도 같다 — Fs.close(own f)는 Fs 권한을 요구한다.

따라서 순서: D4 → resource/with → Mini Shell로 세 번째 점.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
This commit is contained in:
2026-08-30 19:09:09 +09:00
co-authored by Claude Opus 5
parent d85816abae
commit 088e1992a5
6 changed files with 260 additions and 2 deletions
+10 -1
View File
@@ -492,7 +492,16 @@ prelude는 없다. std도 명시적으로 가져온다 — 암묵적으로 끌
건너뛰는데 검사기는 통과시킨다(v0 정책: 오용 금지, 누수 허용). 제대로 정리하면
18줄이 58줄이 되고 5단 중첩 match가 된다. resource/with가 필요한 이유가 여기
숫자로 있다.
열린 것: 함수 타입에 own이 없어 고차 경계에서 소유권 검사가 뚫린다.
6번(DB Pool)까지 두 점을 모아 resource/with를 넣기로 결정했다. 근거:
자원을 들면 ?를 한 번도 못 쓴다(2/2), 같은 정리 패턴을 각자 발명했다(2/2),
누수가 조용히 통과한다(2/2), 2.3~3.2배.
고통은 자원 개수가 아니라 정리의 균일성에 비례한다 — 파일은 단계마다 정리가
달라 4단 중첩이 되고, 풀은 언제나 반납이라 1단으로 접힌다.
순서가 정해졌다: 먼저 수신자를 소비하는 메서드(자원이 자기 정리를 스스로
선언할 수 있어야 한다), 그다음 resource/with. release(pool, own lease)처럼
정리에 다른 값이 필요하면 with가 자동으로 부를 수 없기 때문이다.
고침: 함수 타입과 클로저 파라미터의 own. 그 구멍이 std의 fold가 틀린 것을
덮고 있었다 — 누적자는 매 단계 소비되므로 own이다.
전문은 dogfoods/FINDINGS.md.
■ 개밥 먹기 1 — samples/app (2026-08)
+115
View File
@@ -191,3 +191,118 @@ v0에서는 하지 않는다.
| 조기 반환으로 누수 | **통과** — v0 정책("오용 금지, 누수 허용") |
마지막 줄이 D3이고, `with`가 닫으려는 자리다.
---
## 6. DB Connection Pool (2026-08-30)
목적: 자원 모델의 **두 번째 데이터 점**. 파일에 없던 셋을 압박한다 —
자원이 두 층(풀/lease), 반납이 파괴가 아님, 획득이 실패할 수 있음.
산출물: `std-draft/db.cool` 50줄, `db-pool/naive.cool` 25줄,
`db-pool/careful.cool` 58줄.
### D8. 인자 안에서 소비와 사용을 섞을 수 없다
```cool
release_then(db, pool, l, db.query(l, sql))
^ ^
소비 사용
```
```
l은(는) 이미 move되었습니다 (13:41에서 소비)
```
move 검사가 인자를 왼쪽부터 걷기 때문이다. **실제 평가 순서와 무관하다**
사람은 "질의가 먼저 돌고 그다음 반납"이라고 읽지만 검사기는 그렇게 안 본다.
우회는 쉽다. 먼저 `let`으로 묶고 넘긴다. 다만 **왜 안 되는지가 코드에서
안 보이므로** 진단 메시지가 위치만 말하고 이유를 말하지 않는다.
### 두 사례 비교 — 이것이 `with` 결정의 근거다
| | naive | careful | 배수 | match 중첩 | 정리 도우미 |
|---|---|---|---|---|---|
| Atomic Updater | 18 | 58 | **3.2** | **4단** | 2개 |
| DB Pool | 25 | 58 | **2.3** | **1단** | 2개 |
**공통 (2/2)**
1. **자원을 들면 `?`를 한 번도 못 쓴다.** 예외 없이 그렇다
2. **"정리하고 결과를 그대로 실어나르는 도우미"를 양쪽이 각자 발명했다**
(`abandon`/`discard`, `release_then`/`close_then`). 같은 패턴이 두 번
독립적으로 나온 것은 그것이 진짜 추상이라는 뜻이다
3. **누수는 조용히 통과한다** — 검사기가 아무 말도 안 한다
**다른 점 — 그리고 이게 중요하다**
파일은 4단 중첩이고 풀은 1단이다. 이유는 **정리가 단계마다 다른가**이다.
- 파일: 닫기 전이면 `close + remove`, 닫은 뒤면 `remove`만 → 단계마다 다름
- 풀: 성공이든 실패든 언제나 `release` → 하나로 접힘
**고통이 자원 개수가 아니라 정리의 균일성에 비례한다.** `with`의 값도
그만큼 달라진다.
### D9. `with`가 성립하려면 D4를 먼저 풀어야 한다
풀 사례가 `with`의 설계 제약 하나를 드러냈다.
```cool
with l = db.acquire(pool)? { ... } // 나갈 때 무엇을 부르나?
```
반납은 `db.release(pool, l)`이다 — **풀과 권한이 필요하다.** 그런데 `with`
자동으로 부르려면 **자원만으로 정리를 표현할 수 있어야 한다.** 즉:
```cool
resource Lease {
release close(own self) effects {Db.release} -> Result[Unit, DbError]
}
```
`own self`가 필요하다. 그런데 **capability 메서드는 수신자를 소비할 수
없다(D4)**. `own`은 파라미터에만 붙는다.
파일 쪽도 같다 — `Fs.close(own f)``Fs` 권한을 요구하므로 자원만으로는
정리가 안 된다.
**따라서 순서가 정해진다: D4 → resource/with.** 자원이 자기 정리를 스스로
선언할 수 있어야 `with`가 성립한다. 런타임이 lease 안에 풀을 넣어 두면
되므로 구현상의 문제는 아니지만, **언어에 표현할 자리가 없다.**
### 잘 된 것 — 설계가 버틴 부분
- **두 층 자원이 자연스럽게 중첩된다.** 풀이 바깥, lease가 안쪽
- **반납 ≠ 파괴가 시그니처로 표현된다.** `release(p: Pool, own l: Lease)`
- **획득 실패가 `Result`로 자연스럽다.** `Exhausted`가 호출자에게 선택을 준다
- `List[String]``List`는 내장 타입이라 import가 필요 없다. 모듈 `List`
가져오는 것은 그 타입의 함수를 쓸 때뿐이다 — 미사용 import lint가 정확히
이것을 지적했다
---
## 결정 — resource / with
**넣는다. 다만 D4가 먼저다.**
근거는 위 표다. 데이터 두 점에서 공통으로:
- `?`를 자원과 함께 쓸 수 없다 (2/2)
- 같은 정리 패턴을 각자 발명했다 (2/2)
- 누수가 조용히 통과한다 (2/2)
- 2.3 ~ 3.2배
그리고 `with`가 암묵적 drop보다 나은 이유가 여기서도 확인된다 — 정리 지점이
닫는 중괄호로 보이고, 정리의 effect가 effects 절에 나타나 검사된다.
**순서**
1. **D4** — 수신자를 소비하는 메서드. 자원이 자기 정리를 스스로 선언할 수
있어야 한다
2. **`resource` 종류와 `with`** — 렉시컬 수명, 정리 강제, 전이 규칙
(자원을 필드로 가진 타입은 전이적으로 자원)
3. 그 뒤 Mini Shell로 세 번째 데이터 점
**미루는 것**: crash 시 정리 여부. `with`를 넣어도 이 질문은 열려 있다 —
정상 종료·`return`·`?`에서는 돌고 crash에서는 안 도는 것이 지금의 잠정
답이지만, 감독(supervision)이 들어오면 다시 봐야 한다.
+1 -1
View File
@@ -57,7 +57,7 @@ run ✗
| 3 | Process Supervisor | 실패 위상 | 대기 |
| 4 | Reverse Proxy | 취소, backpressure | 대기 |
| 5 | TCP Server | 규모의 API 모양 | 대기 |
| 6 | DB Pool | lease, 자원 정리 | 대기 |
| 6 | DB Pool | lease, 자원 정리 | **완료** |
| 7 | Resource Exhaustion | 실패 의미론의 최악 조건 | **미룸** — 런타임 속성이라 안 돌리면 알 수 없다. 언어 층 질문("무엇이 Result이고 무엇이 crash인가")은 std-draft 규율로 흡수했다 |
순서는 자원 모델(2, 6, 1) → 동시성·취소(3, 4, 5)다. 2번을 먼저 둔 이유는
+58
View File
@@ -0,0 +1,58 @@
// 제대로 반납하는 버전.
//
// Atomic Updater와 비교하는 것이 목적이다. 자원이 두 층이고, 반납이
// 파괴가 아니며, 획득이 실패할 수 있다 — 파일에는 셋 다 없었다.
import "../std-draft/db" as Db
// 한 요청. 성공하든 실패하든 lease를 반납한다.
pub fn load_user(db: Db.Db, pool: Db.Pool, sql: String)
effects {Db.acquire, Db.query, Db.release} -> Result[List[String], Db.DbError] {
match db.acquire(pool) {
Err(e) => Err(e),
// l을 소비하는 인자와 l을 쓰는 인자를 한 호출에 섞을 수 없다.
// move 검사는 인자를 왼쪽부터 걷기 때문이다 — 실제 평가 순서와
// 무관하다. 먼저 질의하고 결과를 넘긴다.
Ok(l) => {
let r = db.query(l, sql)
release_then(db, pool, l, r)
},
}
}
// 반납하고 원래 결과를 그대로 돌려준다.
// 반납 실패는 일부러 버린다 — 원래 결과가 더 중요하다.
//
// 파일 때와 다른 점: 정리가 실패 경로마다 다르지 않다. 성공이든 실패든
// 같은 일(반납)을 하므로 결과를 통째로 실어나르는 도우미 하나로 접힌다.
pub fn release_then(
db: Db.Db,
pool: Db.Pool,
own l: Db.Lease,
r: Result[List[String], Db.DbError],
) effects {Db.release} -> Result[List[String], Db.DbError] {
let _ = db.release(pool, l)
r
}
// 프로그램 하나. 풀을 열고 쓰고 닫는다.
pub fn run(db: Db.Db, url: String, sql: String)
effects {Db.pool, Db.acquire, Db.query, Db.release, Db.close_pool}
-> Result[List[String], Db.DbError] {
match db.pool(url, 8) {
Err(e) => Err(e),
Ok(pool) => {
let rows = load_user(db, pool, sql)
close_then(db, pool, rows)
},
}
}
pub fn close_then(
db: Db.Db,
own pool: Db.Pool,
r: Result[List[String], Db.DbError],
) effects {Db.close_pool} -> Result[List[String], Db.DbError] {
let _ = db.close_pool(pool)
r
}
+25
View File
@@ -0,0 +1,25 @@
// 순진한 버전 — ?로 짧게 쓴 것.
//
// Atomic Updater와 같은 병이 나오는지 본다. 다만 자원이 두 층이다 —
// 풀과 lease. 그리고 반납은 파괴가 아니다.
import "../std-draft/db" as Db
// 한 요청. lease를 빌리고, 질의하고, 반납한다.
pub fn load_user(db: Db.Db, pool: Db.Pool, sql: String)
effects {Db.acquire, Db.query, Db.release} -> Result[List[String], Db.DbError] {
let l = db.acquire(pool)?
let rows = db.query(l, sql)? // 실패하면 lease가 반납되지 않는다
db.release(pool, l)?
Ok(rows)
}
// 프로그램 하나. 풀을 열고 쓰고 닫는다.
pub fn run(db: Db.Db, url: String, sql: String)
effects {Db.pool, Db.acquire, Db.query, Db.release, Db.close_pool}
-> Result[List[String], Db.DbError] {
let pool = db.pool(url, 8)?
let rows = load_user(db, pool, sql)? // 실패하면 풀도 안 닫힌다
db.close_pool(pool)?
Ok(rows)
}
+51
View File
@@ -0,0 +1,51 @@
// 데이터베이스 연결 풀 — 개밥 먹기용 초안.
//
// 세 층이 있고 수명을 관리하는 주체가 각각 다르다.
// Conn 실제 연결. 풀이 소유한다. 사용자는 존재도 모른다
// Pool 연결 묶음. 프로그램 수명. 사용자가 만들고 닫는다
// Lease 빌린 한 자리. 요청 하나 수명. 반드시 반납해야 한다
//
// 사용자가 관리하는 것은 연결의 수명이 아니라 lease의 수명이다.
// 그래서 반납은 "파괴"가 아니다 — 연결은 풀로 돌아간다.
// ※ List[String]의 List는 내장 타입이라 import가 필요 없다. 모듈 List를
// 가져오는 것은 그 타입에 딸린 함수(len, map...)를 쓸 때뿐이다.
pub enum DbError {
// 연결 자체가 안 된다
Unreachable(String),
// 풀이 가득 찼고 기다릴 수 없다. 호출자가 대처할 수 있다 — 재시도든 포기든
Exhausted,
// 질의가 틀렸다. 프로그램의 결함에 가깝지만 런타임 값에서 오므로 Result다
BadQuery(String),
Other(String),
}
// 풀. capability이므로 affine이다 — 복제되지 않는다.
pub capability Pool {
}
// 빌린 한 자리. 반드시 반납해야 한다.
// ※ 언어는 아직 그 "반드시"를 강제하지 못한다 (D3).
pub capability Lease {
}
// 데이터베이스에 손댈 권한. 풀을 만들 수 있는 유일한 출처다.
pub capability Db {
// 풀을 연다. size는 최대 동시 연결 수.
fn pool(url: String, size: Int) effects {Db.pool} -> Result[Pool, DbError]
// 풀을 닫는다. 안에 있는 연결이 전부 정리된다.
// 아직 빌려나간 lease가 있으면? — 아래 발견 참고.
fn close_pool(own p: Pool) effects {Db.close_pool} -> Result[Unit, DbError]
// 한 자리를 빌린다. 없으면 Exhausted — 기다리지 않는다.
// 기다리는 형태(timeout)는 취소를 정해야 해서 미룬다.
fn acquire(p: Pool) effects {Db.acquire} -> Result[Lease, DbError]
// 반납한다. 연결은 풀로 돌아간다 — 파괴가 아니다.
fn release(p: Pool, own l: Lease) effects {Db.release} -> Result[Unit, DbError]
// 빌린 자리로 질의한다. lease는 빌린다.
fn query(l: Lease, sql: String) effects {Db.query} -> Result[List[String], DbError]
}