없는 기능이 가리는 것 — 하나의 구조적 구멍 아래 있던 평범한 버그 둘

mere-ruby의 IO는 약했고 이유는 분명해 보였다. 이 인터프리터의 File은 파일 디스크립터를 갖지 않는다. 디스크립터를 넣으려던 참이었다. 먼저 측정하니 디스크립터가 움직일 수 있는 것은 35행 중 8행이었고, 대신 한 작업이 그 구조적 설명에 가려져 있던 평범한 버그 두 개를 끄집어냈다. 작업의 순서가 '무엇을 찾을 수 있는가'를 결정한다는 이야기.

mere-rubyrubymeasurementdebuggingdeveloper-experiencetechnical-debt

mere-ruby는 내가 만들고 있는 작은 ML 계열 언어 Mere로 쓴 Ruby 서브셋 인터프리터다. 적합성 기록은 저장소에 들어 있는 표로, ruby/spec의 모든 그룹을 진짜 ruby와 바이트 단위로 대조한 것이다.

core/io는 그 표에서 적자가 가장 큰 그룹 중 하나였다 — 81개 파일 중 45개 일치, 35개 불일치. 이유는 이미 결론이 난 것처럼 보였고 프로젝트 자신의 갭 목록에도 적혀 있었다. 여기의 File은 경로와 모드와 버퍼로 되어 있고 파일 디스크립터를 갖지 않는다. #fileno는 지어낸 번호를 돌려주지 않고 거부하며 이유를 말한다.

이것은 진짜 제약이고 많은 것을 설명한다. 그래서 다음에 할 일은 분명해 보였다 — 인터프리터에 진짜 디스크립터를 주는 것. 제대로 열고, 오프셋을 들고, 그 위에 IO 표면을 올린다. 며칠짜리의, 구조적이고 보람 있는 작업이다.

먼저 측정했다. 측정은 다른 말을 했다.

디스크립터가 실제로 사는 것

이 인터프리터가 답하지 못하는 IO 메서드는 43개다. 이것을 하나의 질문으로 갈랐다 — 이건 디스크립터가 필요한가, 아니면 경로와 위치만으로 되는가.

디스크립터 필요   19   advise autoclose? close_on_exec? fcntl fdatasync
                       for_fd fsync ioctl pid popen read_nonblock sysopen
                       sysseek timeout to_i wait_priority wait_readable
                       wait_writable write_nonblock

디스크립터 불필요 24   binmode binmode? close_read close_write copy_stream
                       eof eof? lineno open pos pread printf pwrite reopen
                       rewind seek set_encoding set_encoding_by_bom stat
                       syswrite tell to_io try_convert ungetbyte ungetc

43개 중 24개는 인터프리터가 이미 가진 것으로 표현할 수 있다. 그리고 실패하는 35개 파일의 내역도 같은 모양이었다 — 15개는 메서드가 없고, 8개는 “디스크립터가 없다”는 명시적 거부다. 디스크립터가 움직이는 것은 8행 남짓이다.

반면 디스크립터는 모델을 바꾼다. 지금의 File은 경로와 버퍼이고 읽기·쓰기 경로가 모두 그렇게 쓰여 있다. 디스크립터와 전체 파일 버퍼링은 공존하지 못한다 — 한쪽을 넣는 것은 다른 쪽을 다시 쓰는 일이다. 8행을 움직이려고 며칠을 쓰고, 끝난 시점에 나머지 24개 이름은 여전히 쓰이지 않은 채로 남는다.

그래서 24개를 먼저 했다. 더 커서가 아니라 아무것도 결정하지 않아도 되는 부분이어서였다.

그 아래 있던 버그 둘

한 바이트씩 읽는 메서드들을 쓴다는 것은 read(1)을 루프로 부른다는 뜻이다. 처음 쓴 하나가 파일 전체를 돌려줬다.

f = File.open("/tmp/ten-bytes.txt")
f.read(3)   # "abcdefghij"   ← 전부
f.pos       # 10
f.read(3)   # ""

File#read는 길이 인자를 무시하고 있었다. 파일 전체를 읽고 위치를 끝으로 옮기고, 다음 읽기에는 아무것도 남지 않는다. 나눠 읽는 코드 — 즉 읽는 코드의 대부분 — 은 거대한 한 덩어리와 그 뒤의 EOF만 보고 있었다.

클래스 메서드 쪽도 같은 모양이었다.

File.binread(path, 3, 2)   # "abcdefghij", ruby는 "cde"

둘 다 오래 거기 있었다. 둘 다 고쳐서, 이 프로젝트가 모든 것의 오라클로 삼는 참조 구현과 대조했다.

둘 다 파일 디스크립터와는 아무 상관이 없다.

왜 보이지 않았는가

여기가 남기고 싶은 부분이다.

구조적 설명은 편안하다. “IO가 약한 것은 디스크립터가 없기 때문이다”는 참이고, 구체적이고, 실패의 모양을 설명한다. 일단 그것을 손에 쥐면 사람은 찾기를 멈춘다 — 의도적으로가 아니라 질문이 답해진 것처럼 느껴지기 때문이다. 그 그룹이 나쁜 것은 이미 아는 이유 때문이라고.

그러나 read가 길이를 무시한 것은 그 이유가 아니다. 같은 그룹 안에, 같은 표제 뒤에 앉아 있었고, 표제가 그것을 감싸주고 있었다.

그리고 작업의 순서가 찾을 수 있는지를 결정한다. 디스크립터를 먼저 만들었다면 read(len)은 그 작업의 일부로 다시 쓰였을 것이다. 버그는 수백 줄 diff 속으로 사라졌을 것이다 — 아마 고쳐지긴 하지만 보이지는 않는다. 길이 인자가 내내 무시되고 있었다는 사실을 나는 배우지 못하고, 개선을 디스크립터의 공으로 돌렸을 것이다.

그것이 수정과 발견의 차이다. 수정은 코드를 바꾼다. 발견은 코드에 대한 내 믿음을 바꾸고, 그 믿음이야말로 다음 작업을 고를 때 쓰는 것이다.

일반화된 모양

이제 이렇게 생각한다 — 없는 기능은 그림자이고, 평범한 버그는 그 안에 앉아 있다.

그림자를 드리우는 것은 사람이 그 코드에 닿는 것을 막는 모든 것이다.

  • 존재하지 않는 클래스. 그 뒤의 것은 한 번도 실행되지 않는다
  • NotImplementedError를 던지는 메서드. 그 호출부는 결코 돌지 않는다
  • “없다”고 모두가 아는 기능. 그 영역은 보고조차 되지 않는다

호출하는 쪽이 들어가지 못하는 동안 안에 있는 것은 측정되지 않는다. 문이 열리는 순간 뒤에 있던 것이 처음으로 시험받는다 — 그리고 그 일부는 애초에 문과 상관이 없었다.

같은 프로젝트에서 이것을 알아채지 못한 채 한 번 본 적이 있다. gzip 라이터에 빠져 있던 메서드 하나를 더했더니 — 테스트 스위트가 쓰는 생성자다 — 그때까지 도달 불가능했던 spec이 돌기 시작했고, 즉시 하네스의 CPU 상한에 걸렸다. 압축 엔진은 1 MB당 약 34초였다. 이 비용은 내내 거기 있었다. 아무도 도달하지 않았으니 아무도 측정하지 않았고, 기록에는 “메서드가 없다”는 깔끔한 오류가 떠 있었다. 진실은 “메서드가 없고 또한 성능 문제가 있다”였다.

나에게 말한다면

셋, 중요한 순서대로.

증상이 아니라 설명을 측정한다. “IO가 약한 것은 디스크립터가 없기 때문이다”는 “디스크립터가 몇 행을 움직이는가”에 대한 주장이다. 그 수는 코드를 한 줄도 쓰기 전에 셀 수 있다. 35행 중 8행이었다.

판단이 필요 없는 작업을 먼저 잡는다. 24개의 이름에는 설계 판단이 필요 없었다 — 이미 있는 것으로 표현 가능했다. 디스크립터는 File 모델 전체에 대한 판단을 요구한다. 막히지 않은 작업을 먼저 하는 것은 미루기가 아니다 — 그 판단에 필요한 측정을 만들어내는 것이 그것이다.

문을 연 직후에 나온 것은 잡음이 아니라 증거로 다룬다. 새로 도달 가능해진 영역이 대뜸 무관한 방식으로 실패한다면 그것은 불운이 아니다. 그림자가 걷힌 것이다.

디스크립터에 대한 판단은 아직 열려 있다. 다만 이제는 직감이 아니라 숫자를 들고 결정할 수 있고, 그 뒤에 숨은 버그는 둘 줄었다.

← Back to Notes