dogfoods: LRU 캐시 — 비렉시컬 정리, 그리고 Option/Result 패턴의 큰 버그

파일과 풀은 정리 지점이 렉시컬이었다. LRU는 아니다 — 축출은 삽입의 부작용으로
예측할 수 없는 때에 일어난다. with를 구현하기 전에 알아야 할 자리였다.

D10 (큰 것) — Option/Result 패턴이 타입을 통째로 잃고 있었다.
  match r { Ok(v) => v, Err(e) => e }   // Int를 String 자리에 두는데 통과
typecheck의 env.ctors에 내장 생성자가 없어서 None이 "None이라는 이름의 변수
바인딩"이 되고 Ok(v)의 v가 TUnknown이 됐다. 즉 이 언어의 핵심 오류 처리
수단의 패턴 매칭이 타입을 전혀 검사하지 않고 있었다. 소진성 검사는 자기
is_ctor에 내장을 갖고 있어 이 사실을 덮었다 — 빠진 경우는 잡으면서 타입은
안 봤다. 내장 열거형을 등록해 고쳤고 기존 코드는 하나도 안 깨졌다.
여태 안 보인 이유: Ok(v) => Int.show(v) 같은 정상 코드는 v가 TUnknown이어도
통과하므로 아무도 이상함을 못 느낀다. 틀린 코드를 써봐야 드러난다.

D11 — struct 필드에서 affine 값을 꺼낼 수 없다. 필드 접근은 빌림이고 struct
분해 패턴이 없다. 열거형은 패턴이 분해하므로 가능하다. 즉 "둘을 함께
돌려주기"가 열거형으로만 된다. 튜플이 없는 대가가 표현 가능성 문제로 나왔다.

D12 — 축출은 렉시컬이 아니다. 그래서 자원이 렉시컬 전용이면 자원 컨테이너를
언어로 만들 수 없다. 이미 그렇게 되어 있었다 — std-draft의 Pool이 불투명한
런타임 capability인 이유가 만들 수 없어서였다. (a) 렉시컬 전용을 택하고,
사용자가 자기 자원 풀을 못 만든다는 대가를 기록한다.

자료구조 자체의 마찰: Map이 없어 O(n), 튜플이 없어 운반용 struct 세 개,
get이 캐시를 새로 돌려줘야 함, 뒤에서 자르는 std 함수 없음.

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:16:36 +09:00
co-authored by Claude Opus 5
parent 088e1992a5
commit 450e96d326
7 changed files with 301 additions and 0 deletions
+9
View File
@@ -502,6 +502,15 @@ prelude는 없다. std도 명시적으로 가져온다 — 암묵적으로 끌
정리에 다른 값이 필요하면 with가 자동으로 부를 수 없기 때문이다. 정리에 다른 값이 필요하면 with가 자동으로 부를 수 없기 때문이다.
고침: 함수 타입과 클로저 파라미터의 own. 그 구멍이 std의 fold가 틀린 것을 고침: 함수 타입과 클로저 파라미터의 own. 그 구멍이 std의 fold가 틀린 것을
덮고 있었다 — 누적자는 매 단계 소비되므로 own이다. 덮고 있었다 — 누적자는 매 단계 소비되므로 own이다.
LRU 캐시가 제약 하나를 더했다: 축출은 렉시컬이 아니므로 with가 못 닿는다.
따라서 자원은 렉시컬 전용으로 간다 — 자원을 담는 컨테이너(풀, 캐시,
레지스트리)는 런타임만 만들 수 있고, 사용자는 언어로 못 만든다. 그 대가를
알고 택한다. 이미 그렇게 되어 있었다 — std-draft의 Pool이 불투명한 런타임
capability인 이유가 만들 수 없어서였다.
그리고 큰 버그 하나를 잡았다: Option/Result의 패턴이 타입을 통째로 잃고
있었다. 내장 생성자가 typecheck에 등록되지 않아 None이 변수 바인딩이 되고
Ok(v)의 v가 TUnknown이었다. Ok(v) => v 로 Int를 String 자리에 두는 코드가
통과했다. 소진성 검사는 자기 목록에 내장을 갖고 있어 이 사실을 덮고 있었다.
전문은 dogfoods/FINDINGS.md. 전문은 dogfoods/FINDINGS.md.
■ 개밥 먹기 1 — samples/app (2026-08) ■ 개밥 먹기 1 — samples/app (2026-08)
+100
View File
@@ -282,6 +282,102 @@ resource Lease {
--- ---
## LRU 캐시 (2026-08-30)
목적: 앞의 둘이 못 건드린 데를 친다. 파일과 풀은 정리 지점이 **렉시컬**이었다.
LRU는 아니다 — **축출은 삽입의 부작용으로 예측할 수 없는 때에 일어난다.**
산출물: `lru-cache/values.cool` 82줄, `lru-cache/resources.cool` 73줄.
### D10. Option/Result 패턴이 타입을 통째로 잃고 있었다 — 고침. 큰 것이었다
```cool
pub fn probe3(r: Result[Int, String]) -> String {
match r {
Ok(v) => v, // Int를 String 자리에 둔다
Err(e) => e,
}
}
```
**통과했다.** 원인: typecheck의 `env.ctors`에 내장 생성자가 없었다. 그래서
`None`은 생성자가 아니라 **`None`이라는 이름의 변수 바인딩**이 되고,
`Ok(v)``v``TUnknown`이 되어 무엇과도 맞았다.
즉 **이 언어의 핵심 오류 처리 수단인 Option/Result의 패턴 매칭이 타입을 전혀
검사하지 않고 있었다.** 소진성 검사는 자기 `is_ctor`에 내장을 갖고 있어서
이 사실을 덮고 있었다 — 빠진 경우는 잡으면서 타입은 안 봤다.
내장 열거형을 등록해서 고쳤다. 기존 코드는 하나도 안 깨졌다.
이 버그가 여태 안 보인 이유가 씁쓸하다. `match r { Ok(v) => Int.show(v) }`
같은 코드는 v가 TUnknown이어도 통과하므로 **아무도 이상함을 못 느낀다.**
틀린 코드를 써봐야 드러난다.
### D11. struct 필드에서 affine 값을 꺼낼 수 없다
`put`은 캐시와 축출된 자원을 **함께** 돌려줘야 한다. 튜플이 없으니 struct다.
```cool
pub struct Put { cache: LeaseCache, evicted: Option[Db.Lease] }
...
match p.evicted { Some(l) => release(l), ... }
```
```
l은(는) 빌린 값이라 다른 함수에 넘길 수 없습니다
```
**필드 접근은 빌림이고, struct를 분해하는 패턴이 언어에 없다.** 그래서 struct에
넣은 affine 값은 다시 꺼낼 수 없다.
열거형은 된다 — 패턴이 분해하기 때문이다:
```cool
pub enum Put {
Kept(LeaseCache),
Evicted(LeaseCache, Db.Lease),
}
match p { Evicted(c, l) => release_then(db, pool, l, c), ... }
```
**즉 "둘을 함께 돌려주기"가 열거형으로만 가능하다.** 튜플이 없는 대가가
여기서 두 번째로 나온다(F5에 이어). 그리고 이건 취향 문제가 아니라
**표현 가능성 문제**다.
→ 해법 후보: struct 분해 패턴 + 부분 이동, 또는 튜플. 둘 다 문법 결정이다.
### D12. 축출은 렉시컬이 아니다 — `with`가 못 닿는 첫 자리
`with`는 렉시컬 수명만 다룬다. 축출된 lease는 **어느 블록에도 묶이지 않는다.**
삽입할 때 튀어나오고, 그 시점은 캐시 상태에 달려 있다.
그래서 `resource`가 렉시컬 전용이면 **자원을 담는 컨테이너를 언어로 만들 수
없다.** 그리고 이것이 이미 우리 설계에 나타나 있었다 — `Pool` 자체가 자원
캐시인데, `std-draft/db.cool`에서 **런타임이 주는 불투명한 capability**로
선언했다. 만들 수 없어서 그렇게 한 것이다.
두 갈래다:
- **(a) 자원은 렉시컬 전용** → 자원 컨테이너는 런타임만 만들 수 있다.
풀, 캐시, 레지스트리가 전부 언어 밖이 된다. 단순하고, 지금 상태가 그렇다
- **(b) 자원이 자료구조로 탈출할 수 있다** → `with`만으로 부족하고 결국
선형 타입이 필요하다. v0가 미룬 바로 그것
**(a)를 권한다.** 자원 컨테이너는 드물고, 만드는 쪽은 런타임이며, 쓰는 쪽은
lease를 렉시컬하게 빌린다. 다만 **이 선택이 무엇을 포기하는지 적어둬야
한다** — 사용자가 자기 자원 풀을 언어로 못 만든다.
### 자료구조 자체의 마찰 (값 판)
- **Map이 없어 O(n)이다.** LRU의 요점이 O(1)인데 표현할 수단이 없다
- **튜플이 없어 운반용 struct를 세 번 만들었다** — `Got`, `Put`, `Dropping`
- **`get`이 캐시를 새로 돌려줘야 한다.** 최근성이 바뀌므로. 공유 가변 상태가
없다는 것의 대가이고, 호출자가 캐시를 계속 실어날라야 한다
- **뒤에서 자르는 함수가 std에 없다.** `drop_last``reverse` 두 번으로 썼다
- **fold에 "첫 원소 건너뛰기"가 없어** `Dropping` struct를 또 만들었다
---
## 결정 — resource / with ## 결정 — resource / with
**넣는다. 다만 D4가 먼저다.** **넣는다. 다만 D4가 먼저다.**
@@ -303,6 +399,10 @@ resource Lease {
(자원을 필드로 가진 타입은 전이적으로 자원) (자원을 필드로 가진 타입은 전이적으로 자원)
3. 그 뒤 Mini Shell로 세 번째 데이터 점 3. 그 뒤 Mini Shell로 세 번째 데이터 점
**LRU가 더한 제약**: 자원은 렉시컬 전용으로 간다. 자원을 담는 컨테이너는
런타임만 만들 수 있다 — 축출처럼 비렉시컬한 정리는 `with`가 못 닿기 때문이다.
사용자가 자기 자원 풀을 언어로 못 만드는 것이 그 대가다.
**미루는 것**: crash 시 정리 여부. `with`를 넣어도 이 질문은 열려 있다 — **미루는 것**: crash 시 정리 여부. `with`를 넣어도 이 질문은 열려 있다 —
정상 종료·`return`·`?`에서는 돌고 crash에서는 안 도는 것이 지금의 잠정 정상 종료·`return`·`?`에서는 돌고 crash에서는 안 도는 것이 지금의 잠정
답이지만, 감독(supervision)이 들어오면 다시 봐야 한다. 답이지만, 감독(supervision)이 들어오면 다시 봐야 한다.
+1
View File
@@ -58,6 +58,7 @@ run ✗
| 4 | Reverse Proxy | 취소, backpressure | 대기 | | 4 | Reverse Proxy | 취소, backpressure | 대기 |
| 5 | TCP Server | 규모의 API 모양 | 대기 | | 5 | TCP Server | 규모의 API 모양 | 대기 |
| 6 | DB Pool | lease, 자원 정리 | **완료** | | 6 | DB Pool | lease, 자원 정리 | **완료** |
| + | **LRU Cache** | **비렉시컬 정리 (축출)** | **완료** |
| 7 | Resource Exhaustion | 실패 의미론의 최악 조건 | **미룸** — 런타임 속성이라 안 돌리면 알 수 없다. 언어 층 질문("무엇이 Result이고 무엇이 crash인가")은 std-draft 규율로 흡수했다 | | 7 | Resource Exhaustion | 실패 의미론의 최악 조건 | **미룸** — 런타임 속성이라 안 돌리면 알 수 없다. 언어 층 질문("무엇이 Result이고 무엇이 crash인가")은 std-draft 규율로 흡수했다 |
순서는 자원 모델(2, 6, 1) → 동시성·취소(3, 4, 5)다. 2번을 먼저 둔 이유는 순서는 자원 모델(2, 6, 1) → 동시성·취소(3, 4, 5)다. 2번을 먼저 둔 이유는
+73
View File
@@ -0,0 +1,73 @@
// LRU 캐시 — 자원 판.
//
// 앞의 두 개밥(파일, 풀)은 정리 지점이 렉시컬이었다. 여기는 아니다.
// 축출은 삽입의 부작용으로, 예측할 수 없는 때에 일어난다.
//
// 이것이 with 결정 전에 알아야 할 자리다 — with는 렉시컬 수명만 다루는데
// 축출된 자원은 어느 블록에도 묶이지 않는다.
import "cool.dev/std/list" as List
import "../std-draft/db" as Db
pub struct Held {
key: String,
lease: Db.Lease,
}
// Lease를 필드로 가지므로 전이적으로 affine이다 — 선언하지 않아도 유도된다.
pub struct LeaseCache {
cap: Int,
held: List[Held],
}
// put은 캐시와 "떨어져 나온 자원"을 함께 돌려줘야 한다. 튜플이 없다.
//
// struct로 쓰면 막힌다 — 필드 접근은 빌림이라 affine 값을 꺼낼 수 없고,
// struct를 분해하는 패턴이 언어에 없다. 열거형은 패턴이 분해하므로 꺼낼 수
// 있다. 그래서 "둘을 함께 돌려주기"가 열거형으로만 가능하다.
pub enum Put {
Kept(LeaseCache),
Evicted(LeaseCache, Db.Lease),
}
pub fn empty(cap: Int) -> LeaseCache {
LeaseCache { cap: cap, held: [] }
}
// 넣는다. 넘치면 가장 오래된 것이 떨어져 나온다.
//
// 떨어져 나온 lease는 호출자가 반납해야 한다. 언어는 그것을 강제하지 못한다 —
// Put.evicted를 무시하고 버려도 아무 말이 없다. 누수 허용이므로.
pub fn put(own c: LeaseCache, key: String, own l: Db.Lease) -> Put
// 호출자가 해야 하는 일. 이 함수를 안 부르면 조용히 샌다.
pub fn drain(db: Db.Db, pool: Db.Pool, own p: Put)
effects {Db.release} -> LeaseCache {
match p {
Kept(c) => c,
Evicted(c, l) => release_then(db, pool, l, c),
}
}
pub fn release_then(
db: Db.Db,
pool: Db.Pool,
own l: Db.Lease,
own c: LeaseCache,
) effects {Db.release} -> LeaseCache {
let _ = db.release(pool, l)
c
}
// 쓰는 쪽. 매 삽입마다 축출을 받아 처리해야 한다.
pub fn add(db: Db.Db, pool: Db.Pool, own c: LeaseCache, key: String)
effects {Db.acquire, Db.release} -> Result[LeaseCache, Db.DbError] {
match db.acquire(pool) {
Err(e) => Err(e),
Ok(l) => Ok(drain(db, pool, put(c, key, l))),
}
}
pub fn size(c: LeaseCache) -> Int {
List.len(c.held)
}
+82
View File
@@ -0,0 +1,82 @@
// LRU 캐시 — 값 판.
//
// 먼저 자원 없이 써서 자료구조 자체의 마찰을 본다. 자원 판은 resources.cool.
import "cool.dev/std/list" as List
// ※ Option[Int]의 Option은 내장 타입이라 import가 필요 없다.
pub copyable struct Entry {
key: String,
value: Int,
}
// 최근에 쓴 것이 앞. Map이 없어 리스트를 훑는다 — O(n)이다.
// LRU의 요점이 O(1)인데 그것을 표현할 수단이 언어에 없다.
pub copyable struct Cache {
cap: Int,
entries: List[Entry],
}
pub fn empty(cap: Int) -> Cache {
Cache { cap: cap, entries: [] }
}
// get이 최근성을 바꾸므로 캐시를 새로 돌려줘야 한다.
// 그런데 값도 같이 돌려줘야 한다 — 튜플이 없어 struct를 하나 더 만든다.
pub copyable struct Got {
cache: Cache,
value: Option[Int],
}
pub fn get(c: Cache, key: String) -> Got {
let hit = List.first(List.filter(c.entries, fn(e) { e.key == key }))
match hit {
None => Got { cache: c, value: None },
Some(e) => Got {
cache: Cache { cap: c.cap, entries: touch(c.entries, key, e) },
value: Some(e.value),
},
}
}
// 찾은 항목을 앞으로 옮긴다.
pub fn touch(entries: List[Entry], key: String, e: Entry) -> List[Entry] {
List.concat([e], List.filter(entries, fn(x) { !(x.key == key) }))
}
pub fn put(c: Cache, key: String, value: Int) -> Cache {
let without = List.filter(c.entries, fn(x) { !(x.key == key) })
let added = List.concat([Entry { key: key, value: value }], without)
Cache { cap: c.cap, entries: evict(added, c.cap) }
}
// 넘치면 뒤에서 떨어뜨린다. 값이라 그냥 사라진다 — 자원이면 이야기가 다르다.
pub fn evict(entries: List[Entry], cap: Int) -> List[Entry] {
if List.len(entries) <= cap {
entries
} else {
evict(drop_last(entries), cap)
}
}
// 마지막 하나를 뺀 리스트. 뒤에서 자르는 함수가 std에 없다.
pub fn drop_last(entries: List[Entry]) -> List[Entry] {
List.reverse(drop_first(List.reverse(entries)))
}
pub fn drop_first(entries: List[Entry]) -> List[Entry] {
List.fold(entries, Dropping { first: true, kept: [] }, fn(own d, e) {
if d.first {
Dropping { first: false, kept: d.kept }
} else {
Dropping { first: false, kept: List.push(d.kept, e) }
}
}).kept
}
// fold에 "첫 원소 건너뛰기"가 없어 운반용 struct를 또 만든다.
pub copyable struct Dropping {
first: Bool,
kept: List[Entry],
}
+12
View File
@@ -834,6 +834,18 @@ let check ?(imports : item list = []) (m : modul) : error list =
errors = []; errors = [];
} }
in in
(* 내장 열거형. 이것이 없으면 Option/Result의 패턴이 생성자로 인식되지
않고 변수 바인딩이 되어, match 안에서 타입 정보가 통째로 사라진다.
사용자 정의가 덮을 수 있도록 먼저 넣는다. *)
Hashtbl.replace env.enums "Option"
([ "a" ], [ ("Some", [ T.TVar "a" ]); ("None", []) ]);
Hashtbl.replace env.enums "Result"
([ "a"; "e" ], [ ("Ok", [ T.TVar "a" ]); ("Err", [ T.TVar "e" ]) ]);
List.iter
(fun (v, e) -> Hashtbl.replace env.ctors v e)
[
("Some", "Option"); ("None", "Option"); ("Ok", "Result"); ("Err", "Result");
];
(* 1차: 타입과 생성자 이름부터 (선언 순서에 의존하지 않는다) *) (* 1차: 타입과 생성자 이름부터 (선언 순서에 의존하지 않는다) *)
List.iter List.iter
(fun it -> (fun it ->
+24
View File
@@ -1708,3 +1708,27 @@ let () =
let st = Session.create ~root:"../dogfoods/atomic-update" ~std:"../std" () in let st = Session.create ~root:"../dogfoods/atomic-update" ~std:"../std" () in
Session.load st "../dogfoods/atomic-update/naive.cool"; Session.load st "../dogfoods/atomic-update/naive.cool";
check "누수는 아직 오류가 아니다 (v0 정책)" (Session.errors st = []) check "누수는 아직 오류가 아니다 (v0 정책)" (Session.errors st = [])
(* D10 회귀: Option/Result 패턴이 타입을 나른다.
전에는 내장 생성자가 typecheck의 ctors에 없어 None이 변수 바인딩이 되고
Ok(v)의 v가 TUnknown이었다 — 즉 핵심 오류 처리 수단의 패턴 매칭이 타입을
전혀 검사하지 않았다. *)
let () =
check "Ok(v)의 v가 타입을 갖는다"
(type_has
"fn f(r: Result[Int, String]) -> String {\n\
\ match r {\n\
\ Ok(v) => v,\n\
\ Err(e) => e,\n\
\ }\n\
}"
"String이(가) 필요한데 Int");
check "None은 변수 바인딩이 아니다"
(type_has
"fn f(o: Option[Int]) -> Int {\n\
\ match o {\n\
\ None => \"x\",\n\
\ Some(v) => v,\n\
\ }\n\
}"
"match 팔의 타입이 서로 다릅니다")