Files
coollang/docs/friction.md
T
coolguyandClaude Opus 5 5e70712578 friction: F8 추가 — 미사용 지역 변수를 안 잡는다, 그리고 현황표
투어용 예제를 쓰다 발견했다. 미사용 import는 오류로 막으면서 미사용 지역
변수와 파라미터는 통과시킨다. 규칙이 고르지 않아 보인다.

다만 근거의 성격이 다르다. import를 막은 이유는 재검사 범위를 넓히기
때문이고 그건 이 아키텍처의 실제 비용인데, 죽은 지역 변수에는 그 비용이
없다. 그래서 "고치면 되는 항목"이 아니라 판단이 필요한 항목으로 적는다.

지금은 고치지 않는다. 다음 개밥 먹기에서 죽은 지역 변수를 실제로 남긴 적이
있는지 세어보고 결정한다. 셀 근거가 없으면 넣지 않는 것이 기능 추가 관문의
기본값이다.

문서 맨 앞에 현황표를 넣었다 — 무엇이 해결됐고 무엇이 열려 있는지가 한눈에
보여야 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
2026-08-30 16:27:08 +09:00

12 KiB

개밥 먹기 보고 — samples/app을 쓰면서 걸린 것들

2026-08-30. coollang으로 처음 쓴 "일하는 프로그램" 하나(설정 파서 + 리포트 도구, 2모듈 292줄)에서 실제로 걸린 마찰을 적는다.

v0의 샘플 16개는 전부 검사기를 시험하려고 쓴 것이고, 그래서 "언어가 쓸 만한가"에 대해서는 아무것도 말해주지 않았다. 이 문서가 v0가 남기는 마지막 데이터이자 v1 설계의 첫 입력이다.

기록 원칙: 불편은 증거와 함께 적고, 해법은 제안까지만 한다. 여기서 바로 고치면 그것은 개밥 먹기가 아니라 기능 추가가 된다.


현황 한눈에

항목 상태
F1 리스트의 n번째를 꺼낼 방법이 없다 해결List.first, List.nth
F2 else if가 없다 취소 — 관찰자가 틀렸다
F3 String.concat이 2항이라 중첩 지옥 해결String.join
F4 struct의 한 필드만 바꿀 방법이 없다 열림 — 문법 결정, 보류
F5 fold에 인덱스가 없다 열림 — 튜플 필요, 보류
F6 Option/Result에 조작 함수가 없다 해결std/option, std/result
F7 std와 인터프리터가 어긋날 수 있다 해결 — 양방향 테스트
F8 미사용 지역 변수·파라미터를 안 잡는다 열림 — 판단 필요

열린 셋은 전부 의도적으로 남긴 것이다. F4와 F5는 문법을 건드려야 하고, 표본이 한 사람이 쓴 300줄 하나뿐인데 되돌리기 비싼 축을 움직일 수는 없다. F8은 고치는 것이 옳은지 자체가 불분명하다.

잘 된 것부터

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가 없어서 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.coolfilter를 적으면 lib/interp.ml에도 구현을 넣어야 한다. 두 곳이 어긋나면 검사는 통과하고 실행이 죽는다.

이것은 v0의 구조적 한계이고 v1에서 사라진다(std를 coollang으로 구현). 다만 v0 동안은 std 시그니처와 런타임 구현이 일치하는지 검사하는 테스트가 있어야 한다. 지금은 없다.


F8. 미사용 지역 변수와 파라미터를 안 잡는다 (판단 필요)

투어용 예제를 쓰다 발견했다. 이 코드가 아무 말 없이 통과한다:

pub fn f(used: Int, never_used: Int) -> Int {
    let alive = used + 1
    let dead = 999
    alive
}

미사용 import는 오류로 막아놓고 미사용 지역 변수는 통과시킨다. 둘 다 "쓰지 않는 선언"인데 한쪽만 잡으니 규칙이 고르지 않아 보인다.

다만 근거의 성격은 다르다. import를 막은 이유는 재검사 범위를 넓히기 때문이었고 — 그건 이 아키텍처의 실제 비용이다 — 미사용 지역 변수에는 그런 비용이 없다. 그냥 죽은 코드다.

그래서 이것은 "고치면 되는 항목"이 아니라 판단이 필요한 항목이다. 철학 1(오류를 더 빨리 잡는다)에는 부합하지만, 디버깅 중에 한 줄 주석 처리했다고 컴파일이 막히는 것은 실제로 성가시다. 그 성가심이 잡아주는 버그보다 큰지는 지금 데이터로 알 수 없다.

제안: 지금은 고치지 않는다. 다음 개밥 먹기에서 죽은 지역 변수를 실제로 남긴 적이 있는지를 세어보고 결정한다. 세어볼 근거가 없으면 넣지 않는 것이 기능 추가 관문의 기본값이다.


후속 (같은 날)

F1, F3, F6을 std 보강으로 처리하고 samples/app을 다시 썼다. 문법은 건드리지 않았다 — 표본이 한 사람이 쓴 300줄 하나뿐인데 되돌리기 비싼 축을 움직일 수는 없다.

추가한 것: List.first, List.nth, String.join, 그리고 std/option.cool, std/result.cool (map, unwrap_or, ok_or, map_err, is_ok).

결과:

config.cool 196줄 148줄
main.cool 96줄 96줄
합계 292줄 244줄

출력은 한 글자도 다르지 않다.

F1의 값이 확인됐다. 48줄이 사라졌고 전부 config.cool에서 나왔다 — head_or, second_or, Pick, take_at, first_text, first_entry가 통째로 없어졌다. 예측(40줄쯤)과 실제(48줄)가 맞았다.

// 전
let key = String.trim(head_or(parts, ""))     // + Pick struct + take_at 20줄

// 후
let key = String.trim(Option.unwrap_or(List.nth(parts, 0), ""))

F3은 줄 수로는 값이 안 보인다. main.cool이 96줄 그대로다. 4단 중첩 String.concat을 4줄짜리 String.join 배열로 바꿨으니 줄 수가 같다. 그런데 읽기는 확실히 낫다:

// 전
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), ")")))))))

// 후
c.print(String.join("", [
    "  ", e.key, " = ", Cfg.show_value(e.value),
    "   (", Cfg.type_name(e.value), ")",
]))

줄 수는 읽기 좋음의 대리 지표일 뿐이고, F3에서 그 대리가 깨진다. 다음 개밥 먹기에서는 줄 수 말고 다른 것을 재야 한다.

F7도 처리했다. Interp.implemented 목록과 std/*.cool의 선언이 서로를 덮는지 테스트가 양방향으로 검사한다. 어긋나면 빌드가 깨진다.

남은 것: F4(struct 부분 갱신), F5(fold 인덱스), F8(미사용 지역 변수). 앞의 둘은 문법 결정이거나 튜플이 필요하고, F8은 고치는 것이 옳은지 자체가 불분명하다. 셋 다 표본을 더 모은 뒤에 판단한다.


결론

v1로 넘길 때 F1과 F3이 먼저다. 둘 다 "언어가 틀렸다"가 아니라 "std에 없어서 우회했다"이고, 우회 비용이 코드에 그대로 보인다 — 292줄 중 40줄쯤이 F1 하나 때문에 존재한다.

F2는 취소됐다. 그리고 그것이 이 문서에서 두 번째로 중요한 발견이다: 확인 없이 적은 불편 하나가 "심각" 등급을 달고 v1 설계 입력이 될 뻔했다.

반대로 되돌리기 비싼 결정들은 하나도 후회되지 않았다. capability를 인자로 나르는 것, effect를 시그니처에 적는 것, 실패를 버릴 수 없는 것, 소진적 match — 300줄을 쓰는 동안 이 넷이 거추장스러웠던 순간이 없었고, 소진성은 오히려 실수를 잡아줬다.

v0의 질문은 "되돌리기 비싼 결정이 옳은가"였다. 답은 그렇다이고, 남은 불편은 전부 되돌리기 싼 것들이다.