3P by GN⁺ | ★ favorite | 댓글 1개
  • 2010년에 작성된 Java humanReadableByteCount 답변은 2018년 연구에서 가장 많이 복사된 Stack Overflow 코드 조각으로 확인됐지만, 바이트 크기 포맷의 경계값에서 잘못된 결과를 냈음
  • 이 코드는 kB, MB, GB 같은 접두사가 1000 또는 1024의 거듭제곱이라는 점을 이용해, 반복문 대신 로그 계산으로 단위를 고르는 방식이었음
  • 핵심 버그는 999,999 bytes가 SI 모드에서 "1000.0 kB"로 출력되는 반올림 경계값 문제였고, 명세상 숫자 범위가 1부터 999.9라면 "1.0 MB"가 맞음
  • 더 큰 값에서는 double부동소수점 정밀도 한계까지 겹쳐 999,949,999,999,999,999 입력이 1000.0 PB로 나왔고, 보정에는 임계값 계산과 스케일 축소, 비트 패턴 보정, strictfp가 필요했음
  • 최종 코드는 음수와 Long.MIN_VALUE까지 처리하지만 원래의 간결함을 잃었고, Stack Overflow 코드 복사에는 엣지 케이스 테스트와 출처 표시가 함께 필요함

2010년 답변이 노린 단순화

  • 문제는 바이트 수를 사람이 읽기 쉬운 문자열로 포맷하는 것이었음
    • 예: 123,456,789 bytes"123.5 MB"처럼 출력
    • 암묵적 명세는 결과 문자열의 숫자 부분이 1부터 999.9 사이이고, 적절한 크기 접미사가 붙는 형태였음
  • 기존 답변은 EB, PB, TB, GB, MB, kB, B를 큰 단위부터 순회하며 바이트 수보다 작은 첫 단위를 고르는 반복문 기반 접근이었음
  • 새 답변은 반복문과 분기를 줄이기 위해 Math.logMath.pow를 사용함
    • SI 모드에서는 단위가 1000
    • 바이너리 표기에서는 단위가 1024
    • exp = log(bytes) / log(unit) 값을 정수로 변환해 접두사 인덱스로 사용
    • 접두사는 SI에서 "kMGTPE", 바이너리에서 "KMGTPE"를 사용하고 바이너리에는 "i"를 덧붙임

복사 실태와 OpenJDK 에피소드

  • Sebastian Baltes의 논문 Usage and Attribution of Stack Overflow Code Snippets in GitHub Projects는 Stack Overflow 코드 조각이 GitHub 프로젝트에서 어떻게 사용되고 출처가 표시되는지 분석함
  • 분석 방식은 Stack Overflow 데이터 덤프에서 코드 조각을 추출한 뒤 공개 GitHub 저장소 코드와 대조하는 것이었음
    • 핵심 질문은 Stack Overflow의 CC BY-SA 3.0 라이선스에 맞는 출처 표시가 지켜지는지였음
    • 결과적으로 대부분의 사용자는 적절한 출처 표시를 포함하지 않았음
  • 답변 ID 3758880은 논문 표에서 최상단에 있었고, 당시 수십만 회 조회와 1,000개 이상의 업보트를 받은 상태였음
  • GitHub에서 humanReadableByteCount를 검색하면 수천 건의 사용 사례가 나타났고, 로컬 저장소에서는 다음 명령으로 확인할 수 있음
git grep humanReadableByteCount
  • OpenJDK 저장소에서도 일치 사례가 발견됨
    • 해당 코드에는 출처 표시가 없었고, OpenJDK 라이선스는 CC BY-SA 3.0과 호환되지 않았음
    • Sebastian Baltes는 OpenJDK 개발 메일링 리스트에 코드가 Stack Overflow에서 OpenJDK로 복사됐는지, 반대였는지 질문함
    • 답변 작성자는 해당 커밋이 병합되기 전에는 Oracle에 입사하지 않았고, 그 패치에도 기여하지 않았음
    • 이후 이슈가 등록됐고 코드는 제거

첫 번째 버그: 999가 이어지는 경계값

  • 겉으로 의심할 만한 문제들은 실제 원인이 아니었음
    • long의 최댓값은 2^63 - 1, 약 9.2 × 10^18이므로 EB 이후 단위까지 넘어가지 않음
    • bytes < unit인 경우를 첫 번째 if가 처리하므로 exp가 0이 되어 charAt(exp - 1)이 실패하지도 않음
  • 실제 문제는 반올림 경계값이었음
    • 입력 999,999 bytes는 SI 모드에서 "1000.0 kB"가 됨
    • 숫자 부분이 1부터 999.9 사이여야 한다는 명세에 따르면 올바른 결과는 "1.0 MB"
  • 작성 시점 기준으로 게시된 22개 답변 모두, Apache Commons와 Android 라이브러리를 쓰는 답변까지 이 버그 또는 그 변형을 갖고 있었음
  • 해결의 핵심은 지수 exp를 언제 다음 단위로 올릴지 정하는 임계값
    • k에서 M으로 바뀌는 시점은 값이 999.9 k보다 1 MB에 더 가까워지는 999,950
    • M에서 G로 바뀌는 시점은 999,950,000
    • 바이너리 모드에서는 임계값이 정수가 아니므로 ceil이 필요함
if (bytes >= Math.ceil(Math.pow(unit, exp) * (unit - 0.05)))
    exp++;

두 번째 버그: double 정밀도 한계

  • 위 보정을 적용해도 999,949,999,999,999,999 입력은 1000.0 PB로 출력됐고, 올바른 결과는 999.9 PB였음
  • 원인은 수학식 자체가 아니라 double 정밀도 한계였음
    • IEEE 754 표현에서는 0에 가까운 부동소수점 값은 촘촘하지만, 큰 값은 매우 성김
    • 매우 큰 double에서는 Long.MAX_VALUE를 빼도 값이 바뀌지 않을 수 있음
double a = Double.MAX_VALUE;
double b = a - Long.MAX_VALUE;
System.err.println(a == b); // prints true
  • 문제가 되는 계산은 두 곳에서 발생함
    • String.format 인자에서 수행하는 나눗셈
    • exp를 올릴지 결정하는 임계값 계산
  • 첫 번째 문제는 중간 bytes 값을 정밀도가 나은 범위로 줄이고 exp를 조정하는 방식으로 처리함
    • 최종 결과는 어차피 반올림되므로 하위 자릿수를 버려도 된다는 전제임
if (exp > 4) {
    bytes /= unit;
    exp--;
}
  • 두 번째 문제에서는 하위 비트가 중요했음
    • 999,949,99…9999,950,00…0은 서로 다른 지수로 분류돼야 함
    • 가능한 임계값은 SI와 바이너리를 합쳐 12개이고, 그중 하나만 잘못된 결과를 냈음
    • 잘못된 결과는 D00으로 끝나는 비트 패턴으로 식별해 보정함
    • 특정 부동소수점 결과의 비트 패턴에 의존하므로 strictfp를 붙였음

음수 입력과 최종 코드

  • Java에는 unsigned long이 없기 때문에 음수 바이트 수 처리도 추가됨
    • 기존에는 -10,000 입력이 -10000 B로 출력됐음
    • absBytes를 도입해 exp 관련 계산은 절댓값 기준으로 수행함
  • Long.MIN_VALUE는 특수 처리가 필요했음
    • -Long.MIN_VALUE == Long.MIN_VALUE이기 때문
    • 따라서 bytes == Long.MIN_VALUE이면 Long.MAX_VALUE를 사용하고, 그 외에는 Math.abs(bytes)를 사용함
  • 최종 버전은 strictfp, 임계값 보정, Long.MIN_VALUE 처리, 큰 지수에서의 스케일 축소를 포함함
  • 반복문과 과도한 분기를 피하려던 코드는 모든 코너 케이스를 다듬은 뒤 원래 버전보다 더 읽기 어려운 코드가 됨
  • 프로덕션 품질의 최신 코드는 별도 글 Formatting byte size to human readable format을 참고할 수 있음

실무에서 남는 교훈

  • Stack Overflow 코드 조각은 수천 개 업보트가 있어도 버그가 있을 수 있음
  • 복사한 코드는 특히 엣지 케이스 테스트가 필요함
  • 부동소수점 산술은 경계값과 큰 수에서 다루기 어려움
  • 코드를 복사할 때는 적절한 출처 표시가 필요하며, 그렇지 않으면 실제 문제가 될 수 있음

댓글과 토론

Hacker News 의견들
  • 하드코딩 값과 if문(또는 while)을 쓰는 답들이 모두 최대 5번 비교를 하는 게 흥미로움
    단위가 B, KiB, MiB, GiB, TiB, EiB까지만이라면 최대 3개의 if문으로도 해결 가능함. GiB 이상인지 확인하면 B/KiB/MiB가 아니라는 걸 알 수 있으니 이분 탐색이 이김
    ZiB와 YiB까지 늘려도 최대 3번 비교면 충분하고, 하드코딩 방식은 최대 7번까지 감. 직접 짠다면 log/pow/부동소수점은 실수 가능성이 너무 커서 안 쓰고, if문을 하드코딩하되 이분 탐색으로 할 것 같음

    • 이분 탐색 방식은 단순히 6번 검사하는 것보다 느릴 수도 있음. 후자는 분기 1개만 탈 가능성이 높고, 분기는 매우 느리니 코드는 가능한 한 직선 흐름으로 두는 게 좋음
    • 입력 분포에 따라 다름. 작은 값이 매우 흔하다면 선형 탐색이 더 나을 수 있음
    • 이건 형편없는 엔지니어링 판단이라고 봄. 단순한 해법은 동료가 쉽게 검토할 수 있고, 경계 조건도 명확하게 보이며 테스트가 이를 덮는지 확인하기 쉬움
      이런 코드는 더 느리고, 더 복잡하고, 테스트와 리뷰도 더 어려운 코드를 쓰기 위해 많은 일을 하는 셈임
  • (2019) 과거 논의들:
    https://news.ycombinator.com/item?id=21693431
    https://news.ycombinator.com/item?id=21698619
    https://news.ycombinator.com/item?id=27533684

  • 이해가 안 됨. 접미사가 7개면 이분 탐색으로 맞는 걸 고르면 되고, 비교 3번이면 됨. 아니면 그냥 단순하게 해도 비교 6번임
    log() 두 번, pow() 한 번, ceil()을 쓰는 게 단순 방식보다 왜 낫다는 건지 모르겠음. 여기서 설명한 버그 자체가 너무 똑똑하게 굴려다 생긴 완벽한 예시임

    • 작성자는 가독성이 떨어진다는 걸 인정하고 다시 루프를 쓰는 방식으로 돌아간 것 같음: https://programming.guide/java/formatting-byte-size-to-human...
      그래도 반올림 버그를 고려하므로 원문 첫 코드 예시보다는 약간 나음
    • 작성자도 처음에 루프보다 실제로 더 낫지는 않다고 말함
      또 비교 6번은 최댓값일 때만이고, 실제 사용에서는 그럴 가능성이 낮아 보임. 대부분 값이 B나 KB 범위라면 선형 방식이 더 나을 수 있음
  • 뻔뻔한 홍보지만, S/O에서 복사하는 대신 사람이 읽기 좋은 형식으로 크기를 빠르고 정확하게 포맷하려면 우리 오픈소스 PrettySize 라이브러리를 쓸 수도 있음. Rust용 [0]과 .NET용 [1]이 있고, 파일 크기에 대한 타입 안전한 논리 연산도 안전하고 쉽게 해줌
    S/O 조각은 4줄이지만, 이 라이브러리들은 훨씬 더 포괄적이고 테스트, 출력 포맷 옵션, 크기 변환 등을 포함
    [0]: https://github.com/neosmart/prettysize-rs
    [1]: https://github.com/neosmart/PrettySize.net

    • 4줄짜리 해법을 방대한 라이브러리로 대체하는 문화가 left-pad를 낳았음
  • 순수한 궁금증인데, StackOverflow의 신뢰할 수 없는 코드를 그냥 복사해 애플리케이션에 붙여 넣는 개발자가 꽤 많은가?
    사람들이 StackOverflow에서 그냥 복사한다는 추측은 유명하지만, 실제로 누군가 하는 걸 보기 전까지는 농담에 가깝다고 생각했음. 나도 익숙하지 않은 영역에서 문제를 풀 때 출발점으로 StackOverflow를 쓰지만, 코드를 그대로 복사한 적은 없음
    보통 조각 코드가 내가 필요한 것만 정확히 해주지는 않기 때문에 API를 살펴보고 설명된 접근을 바탕으로 내 해법을 만들어야 함. 특히 Python에서는 StackOverflow가 유용한 틈새 API 방향을 알려준 적이 많음

    • 예전에 답을 보는 순간 코드에 복사하는 걸 아무도 막을 수 없는 개발자와 일한 적이 있음. 자기가 겪는 문제와 같은지 확인하려고 질문조차 읽지 않았고, 답도 읽지 않았음
      말 그대로 Google → 처음 보이는 Stack Overflow 링크 클릭 → 처음 보이는 코드 블록 복사/붙여넣기였고, 가끔은 언어조차 달랐음. 페어 프로그래밍 중에는 입력 장치를 물리적으로 빼앗아야 했음. 틀렸다고 말하면 말을 끝내기도 전에 페이지의 두 번째 코드 조각을 붙여 넣고 있었고, 이상할 정도로 빨랐음
      극단적인 경우이긴 하지만, “코드가 필요함; Stack Overflow에 코드가 있음; 해결됨!”이라는 사고방식으로 적절한 해법인지 전혀 생각하지 않는 개발자는 많음
    • 실제로 그런 일이 있고, 내가 관심 있는 프로그램 부분의 범위 밖처럼 느껴지는 것일수록 더 자주 일어남
      어차피 우리는 별로 신경 쓰지 않는 배관 작업 부분에 대해 낯선 사람이 만든 라이브러리 코드를 늘 가져다 씀. 파고들어 이해하고 싶다면 직접 짤 가능성이 높지만, 이 부분은 “그냥 작동”하게 두고 프로젝트를 계속 진행하고 싶다면 컴파일러 오류 주도 개발이 됨
    • 저자가 말한 이유 때문에 거의 그대로 복사/붙여넣기는 하지 않음. 대신 해법을 이해하려고 하고, 필요하면 한 줄씩 손으로 베껴 쓰면서 제대로 이해한 뒤 거기서부터 리팩터링함
      변수명도 바꿈. foo, bar, baz가 너무 많아서 사람이 읽기 어려운 경우가 많기 때문임. 같은 문제를 다시 만나면 맹목적으로 복사했을 때보다 내가 뭘 했는지 기억하기도 쉬움
    • 실제로 사람들은 그렇게 함. StackOverflow의 잘못된 TLS 코드와 설정을 엄청나게 많이 본 뒤로, 대부분의 시스템이 인증서를 제대로 검증하지 않은 채 돌아간다고 꽤 확신하게 됨
    • 아마 Adderall 먹은 23살들이 만든 코드베이스에서 일해보는 즐거움을 아직 못 겪어본 듯함
  • log 2가 필요한데 왜 부동소수점 로그를 쓰는지 모르겠음
    내가 놓친 게 없다면, 아래 식은 2^63바이트보다 작은 양수에 대해 floor(log2(value))를 정확히 주고 훨씬 빠름:
    Long.bitCount( (Long.highestOneBit(value) << 1) - 1) - 1

    • “일반적인” 단위는 10의 거듭제곱이라서 이 방식은 맞지 않음
  • 조각 코드를 보자마자 부동소수점 log 연산과 정수에 대한 나눗셈이 보여서, 너무 영리하게 짠 탓에 본질적으로 버그가 나기 쉬운 코드라고 보고 머릿속에서 바로 버렸음

    • 그게 사실상 글의 요지임
  • 지식의 연쇄는 끝까지 내려감. 아주 작은 지식조차 한 번 꺼내면 다시 넣어두기가 얼마나 어려운지 보여줌
    Stack Exchange가 활동 기여자를 빠르게 잃는 상황에서, 나중에 빗나간 것으로 밝혀진 빠른 총잡이 답변을 바로잡으려면 무엇이 필요할지 궁금함. 그리고 이런 “살짝 틀린” 답변들이 검색 기록과 점점 더 LLM 역사 속에 굳어지면 우리의 집단 지식에는 어떤 의미가 될지도 궁금함

  • 기초군사훈련 때가 떠오름. 교관들이 신병들에게 아무도 할 줄 모르는 과제를 일부러 지침 없이 주고 떠나곤 했음
    그러면 누군가가 항상 틀린 방식으로 시작했고, 나머지는 모두 그 사람을 따라 했음

    • 남들보다 나빠 보이고 싶지 않은 인간 성향 때문에 이런 일이 더 심해지는지 궁금함. 똑똑한 사람들도 나쁜 아이디어나 성급한 아이디어를 따라가는 어리석은 결과로 이어질 수 있음
      공개 경제 전망에서도 비슷한 일이 일어남. 남들은 맞혔는데 혼자 틀린 사람은, 모두가 같이 틀린 사람보다 훨씬 가혹하게 취급됨
    • 그 훈련의 목표가 뭐였음?
  • 이런 알고리즘에서 부동소수점 오류를 꼭 “결함”이라고 보지는 않음. 코드가 논리적이고 수학적으로 올바른 해법을 정의한다면 그 자체로는 “맞다”고 봄
    부동소수점 오류를 해결하는 건 그보다 한 단계 위의 일이고, 실제로 중요할 때만 하는 작업임. 부동소수점 오류가 존재하지 않아 고려할 필요도 없는 완벽한 미래의 프로그래밍 언어를 상상할 수 있는데, 내 알고리즘의 99%는 그런 언어를 대상으로 삼는 셈임