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