"else if가 없어서 3~4단 중첩이 된다"고 적었는데 틀렸다. parser.ml:480이 처음부터 else 뒤의 if를 처리한다. 확인하지 않고 습관대로 중첩해 쓰고 언어를 탓한 것이다. 고쳐 쓰니 config.cool이 208줄에서 196줄이 되고 parse_line은 4단 중첩에서 평평한 5갈래가 됐다. 개밥 먹기 자체에 대한 교훈이라 문서에 남긴다. 한 사람이 쓴 300줄에서 나온 불편은 언어의 성질일 수도 있고 그 사람의 습관일 수도 있다. 확인 없이 적은 것 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
7.8 KiB
개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들
2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트 도구, 2모듈 292줄)에서 실제로 걸린 마찰을 적는다.
v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸 만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막 데이터이자 v1 설계의 첫 입력이다.
기록 원칙: 불편은 증거와 함께 적고, 해법은 제안까지만 한다. 여기서 바로 고치면 그것은 개밥 먹기가 아니라 기능 추가가 된다.
잘 된 것부터
1. 시그니처가 프로그램의 전부를 말한다.
pub fn main(c: Console, f: File, a: Args)
effects {Console.print, File.read, Args.all}
이 한 줄을 읽으면 이 프로그램이 할 수 있는 일이 끝난다. 네트워크를 쓸 수
없고, 다른 파일을 쓸 수 없고, 프로세스를 띄울 수 없다 — 문서가 아니라
컴파일러가 보장한다. 300줄을 쓰는 내내 이것이 어색하지 않았다. 오히려
f.read를 쓰려고 File을 인자에 추가하는 순간이 "이 함수가 권한을 하나
더 갖는다"는 사실을 자각하게 만들었다.
2. ?가 기대대로 동작했다.
let path = first_arg(a.all())?
let text = f.read(path)?
읽기 좋고, 실패 경로가 보이고, 삼켜지지 않는다.
3. 소진성이 실전에서 값을 했다.
Value에 경우 하나(List)를 추가해 봤다:
config.cool:54:5: 빠진 경우: List(_)
config.cool:62:5: 빠진 경우: List(_)
config.cool:196:5: 빠진 경우: List(_)
config.cool:204:5: 빠진 경우: List(_)
고쳐야 할 자리 넷을 전부, 정확히 짚었다. 이것이 없으면 show_value만
고치고 get_int를 잊는다.
4. 모듈 경계가 자연스러웠다. 파싱(config)과 출력(main)을 나누는 데
마찰이 없었고, Cfg.Config 같은 한정 이름이 오히려 읽기 좋았다.
걸린 것 — 심각한 순서로
F1. 리스트의 n번째를 꺼낼 방법이 없다 (심각)
인덱싱 연산자를 뺀 결정 자체는 옳다고 본다. 그런데 std에 대안이 없다.
"첫 원소"를 꺼내려고 이 코드를 네 번 썼다:
pub fn first_text(xs: List[String]) -> Option[String] {
List.fold(xs, None, fn(acc, x) {
match acc {
Some(prev) => Some(prev),
None => Some(x),
}
})
}
String.split(line, "=")의 결과에서 앞의 둘을 꺼내는 데는 보조 struct
Pick까지 만들어야 했다 — 순전히 "몇 번째를 보고 있는가"를 나르려고.
304줄 중 약 40줄이 이 문제 하나 때문에 존재한다.
제안:
List.first,List.nth(xs, i) -> Option[a], 그리고String.split같은 자리에서 흔한List.split_first(xs) -> Option[(a, List[a])]. 튜플이 없으므로 마지막 것은 문법 결정을 동반한다.
F2. else if가 없다 — 취소. 관찰자가 틀렸다
else if가 없다처음 이 문서를 쓸 때 "else if가 없어서 3~4단 중첩이 된다"고 적었다.
틀렸다. else if는 처음부터 된다 (lib/parser.ml:480이 명시적으로
else 뒤의 if를 처리한다).
그런데도 config.cool을 중첩 if로 썼다. 언어가 강제한 것이 아니라
내가 확인하지 않고 습관대로 쓴 것이고, 그다음 언어를 탓했다.
고쳐 쓰니 208줄이 196줄이 됐고 parse_line은 4단에서 평평한 5갈래가
됐다:
if String.is_empty(line) {
cfg
} else if String.starts_with(line, "#") {
cfg
} else if List.len(parts) != 2 {
add_problem(cfg, no, ...)
} else if String.is_empty(String.trim(head_or(parts, ""))) {
add_problem(cfg, no, "이름이 비어 있습니다")
} else {
add_entry(cfg, ...)
}
개밥 먹기 자체에 대한 교훈이다. 한 사람이 쓴 300줄에서 나온 불편은 언어의 성질일 수도 있고 그 사람의 습관일 수도 있다. 둘을 나누려면 불편을 적을 때마다 "언어가 정말 막는가"를 확인해야 한다. 확인 없이 적은 것 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다.
나머지 항목들은 확인했다 — F1은 List.first/nth가 std에 실제로 없고,
F3은 String.join이 실제로 없다.
F3. String.concat이 2항이라 중첩 지옥이 된다 (심각)
리포트 한 줄이 이렇게 생겼다:
c.print(String.concat(" ", String.concat(e.key,
String.concat(" = ", String.concat(Cfg.show_value(e.value),
String.concat(" (", String.concat(Cfg.type_name(e.value), ")")))))))
읽을 수 없다. 이 프로그램에서 가장 나쁜 코드이고, 원인은 명확하다.
제안 두 가지. (a)
String.join(sep, List[String])— 작고 안전하다. (b) 문자열 보간"${key} = ${value}"— 훨씬 낫지만 문법과 타입 규칙을 정해야 하고, "한 개념 한 방식"에서 concat과 겹친다. 지금 판단으로는 (a)를 먼저 넣고 (b)는 체감 데이터를 더 모은 뒤.
F4. struct의 한 필드만 바꿀 방법이 없다 (중간)
pub fn add_entry(cfg: Config, e: Entry) -> Config {
Config {
entries: List.push(cfg.entries, e),
problems: cfg.problems, // ← 안 바뀌는데 적어야 한다
}
}
필드가 둘이라 견딜 만하지만 다섯이면 못 쓴다. take_at은 세 필드를 매번
전부 나열한다.
제안:
Config { ..cfg, entries: x }. affine 타입에서는cfg가 move되는 것이므로 소유권 규칙과 충돌하지 않는다. 오히려 명시적이다.
F5. fold에 인덱스가 없다 (중간)
줄 번호를 세려고 struct를 하나 더 만들었다:
pub copyable struct Numbered {
no: Int,
cfg: Config,
}
"인덱스가 필요한 fold"는 드문 요구가 아니다.
제안:
List.fold_indexed. 또는List.enumerate가 더 조합적이지만 튜플이 없어서 지금은 불가능하다. F1의 튜플 문제와 같은 뿌리다.
F6. Option/Result에 조작 함수가 하나도 없다 (중간)
map, unwrap_or, or_else가 없어서 전부 match로 풀었다. match가
나쁜 것은 아니지만, 세 줄이면 될 것이 여섯 줄이 된다.
제안:
Option.map/unwrap_or,Result.map/map_err/unwrap_or. 타입 이름이 함수의 이름공간이라는 규칙이 이미 있으므로 문법 결정은 없다.
F7. std를 늘릴 때마다 인터프리터를 고쳐야 한다 (구조)
std/list.cool에 filter를 적으면 lib/interp.ml에도 구현을 넣어야
한다. 두 곳이 어긋나면 검사는 통과하고 실행이 죽는다.
이것은 v0의 구조적 한계이고 v1에서 사라진다(std를 coollang으로 구현). 다만 v0 동안은 std 시그니처와 런타임 구현이 일치하는지 검사하는 테스트가 있어야 한다. 지금은 없다.
결론
v1로 넘길 때 F1과 F3이 먼저다. 둘 다 "언어가 틀렸다"가 아니라 "std에 없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 292줄 중 40줄쯤이 F1 하나 때문에 존재한다.
F2는 취소됐다. 그리고 그것이 이 문서에서 두 번째로 중요한 발견이다: 확인 없이 적은 불편 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다.
반대로 되돌리기 비싼 결정들은 하나도 후회되지 않았다. capability를 인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것, 소진적 match — 300줄을 쓰는 동안 이 넷이 거추장스러웠던 순간이 없었고, 소진성은 오히려 실수를 잡아줬다.
v0의 질문은 "되돌리기 비싼 결정이 옳은가"였다. 답은 그렇다이고, 남은 불편은 전부 되돌리기 싼 것들이다.